outputTitle(); ?>

@@ -304,17 +295,31 @@ class WebInstallerOutput {
plain(); + // Section 1: External links + // @todo FIXME: Migrate to plain link label messages (T227297). foreach ( explode( '----', $message ) as $section ) { echo '
'; echo $this->parent->parse( $section, true ); echo '
'; } + // Section 2: Installer pages + echo '
    '; + foreach ( [ + 'config-sidebar-readme' => 'Readme', + 'config-sidebar-relnotes' => 'ReleaseNotes', + 'config-sidebar-license' => 'Copying', + 'config-sidebar-upgrade' => 'UpgradeDoc', + ] as $msgKey => $pageName ) { + echo $this->parent->makeLinkItem( + $this->parent->getDocUrl( $pageName ), + wfMessage( $msgKey )->text() + ); + } + echo '
'; ?>
@@ -325,13 +330,14 @@ class WebInstallerOutput { public function outputShortHeader() { ?> getHeadAttribs() ); ?> + - + <?php $this->outputTitle(); ?> getCssUrl() . "\n"; ?> - getJQuery(); ?> - + getJQuery() . "\n"; ?> + diff --git a/includes/installer/WebInstallerWelcome.php b/includes/installer/WebInstallerWelcome.php index 28e8784701..662a40ded2 100644 --- a/includes/installer/WebInstallerWelcome.php +++ b/includes/installer/WebInstallerWelcome.php @@ -33,8 +33,12 @@ class WebInstallerWelcome extends WebInstallerPage { if ( $status->isGood() ) { $this->parent->output->addHTML( '' . wfMessage( 'config-env-good' )->escaped() . '' ); - $this->parent->output->addWikiTextAsInterface( wfMessage( 'config-copyright', - SpecialVersion::getCopyrightAndAuthorList() )->plain() ); + $this->parent->output->addWikiTextAsInterface( + wfMessage( 'config-welcome-section-copyright', + SpecialVersion::getCopyrightAndAuthorList(), + wfExpandUrl( $this->parent->getDocUrl( 'Copying' ) ) + )->plain() + ); $this->startForm(); $this->endForm(); } else { diff --git a/includes/installer/i18n/af.json b/includes/installer/i18n/af.json index c74f637969..04aba37ad4 100644 --- a/includes/installer/i18n/af.json +++ b/includes/installer/i18n/af.json @@ -74,7 +74,6 @@ "config-db-web-account": "Databasisgebruiker vir toegang tot die web", "config-mysql-engine": "Stoor-enjin:", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", "config-site-name": "Naam van die wiki:", "config-site-name-blank": "Verskaf 'n naam vir u webwerf.", "config-project-namespace": "Projeknaamruimte:", diff --git a/includes/installer/i18n/ar.json b/includes/installer/i18n/ar.json index d676a04e4c..46b9c21fb4 100644 --- a/includes/installer/i18n/ar.json +++ b/includes/installer/i18n/ar.json @@ -51,14 +51,18 @@ "config-help-restart": "هل تريد إزالة البيانات المحفوظة التي قد قمت بإدخالها وإعادة تشغيل عملية التثبيت؟", "config-restart": "نعم، إعادة التشغيل", "config-welcome": "=== التحقق من البيئة ===\nسوف يتم الآن التحقق من أن البيئة مناسبة لتنصيب ميديا ويكي.\nتذكر تضمين هذه المعلومات اذا اردت طلب المساعدة عن كيفية إكمال التنصيب.", - "config-copyright": "=== حقوق النسخ والشروط ===\n\n$1\n\nهذا البرنامج هو برنامج حر؛ يمكنك إعادة توزيعه و/أو تعديله تحت شروط رخصة جنو العامة على أن هذا البرنامج قد نُشر من قِبل مؤسسة البرمجيات الحرة؛ إما النسخة 2 من الرخصة، أو أي نسخة أخرى بعدها (من اختيارك).\n\nتم توزيع هذا البرنامج على أمل ان يكون مفيدًا ولكن دون أية ضمانات؛ دون حتى أية ضمانات مفهومة ضمنيًا أو رواجات أو أية أسباب محددة.\nانظر رخصة جنو العامة لمزيد من المعلومات.\n\nمن المفترض أنك استملت نسخة عن رخصة جنو العامة مع هذا البرنامج؛ إذا لم تقعل اكتب رسالة إلى مؤسسة البرمجيات الحرة المحدودة، شارع 51 فرانكلين الطابق الخامس، بوسطن MA 02110-1301 الولايات المتخدة أو [https://www.gnu.org/copyleft/gpl.html read it online].", - "config-sidebar": "* [https://www.mediawiki.org موقع ميدياويكي]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents دليل المستخدم]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents دليل الإداري]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ الأسئلة المتكررة]\n----\n* إقراءني\n* ملاحظات الإصدار\n* النسخ\n* الترقية", + "config-welcome-section-copyright": "=== حقوق النسخ والشروط ===\n\n$1\n\nهذا البرنامج هو برنامج حر؛ يمكنك إعادة توزيعه و/أو تعديله تحت شروط رخصة جنو العامة على أن هذا البرنامج قد نُشر من قِبل مؤسسة البرمجيات الحرة؛ إما النسخة 2 من الرخصة، أو أي نسخة أخرى بعدها (من اختيارك).\n\nتم توزيع هذا البرنامج على أمل ان يكون مفيدًا ولكن دون أية ضمانات؛ دون حتى أية ضمانات مفهومة ضمنيًا أو رواجات أو أية أسباب محددة.\nانظر رخصة جنو العامة لمزيد من المعلومات.\n\nمن المفترض أنك استملت [$2 نسخة عن رخصة جنو العامة] مع هذا البرنامج؛ إذا لم تقعل اكتب رسالة إلى مؤسسة البرمجيات الحرة المحدودة، شارع 51 فرانكلين الطابق الخامس، بوسطن MA 02110-1301 الولايات المتخدة أو [https://www.gnu.org/copyleft/gpl.html read it online].", + "config-sidebar": "* [https://www.mediawiki.org موقع ميدياويكي]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents دليل المستخدم]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents دليل الإداري]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ الأسئلة المتكررة]", + "config-sidebar-readme": "اقرأني", + "config-sidebar-relnotes": "ملاحظات الإصدار", + "config-sidebar-license": "نسخ", + "config-sidebar-upgrade": "ترقية", "config-env-good": "جرى التحقق من البيئة.\nيمكنك تنصيب ميدياويكي.", "config-env-bad": "جرى التحقق من البيئة. لا يمكنك تنصيب ميدياويكي.", "config-env-php": "بي إتش بي $1 مثبت.", "config-env-hhvm": "نصبت HHVM $1.", - "config-unicode-using-intl": "باستخدام [https://pecl.php.net/intl امتداد intl PECL] لتسوية يونيكود.", - "config-unicode-pure-php-warning": "تحذير: لا يتوفر [https://pecl.php.net/intl امتداد intl PECL] للتعامل مع تطبيع يونيكود; حيث يتراجع لإبطاء تنفيذ Pure-Pure;\nإذا كنت تدير موقعا عالي الزيارات، فتجب عليك القراءة قليلا في [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations تطبيع يونيكود].", + "config-unicode-using-intl": "باستخدام [https://php.net/manual/en/book.intl.php امتداد PHP intl] لتسوية يونيكود.", + "config-unicode-pure-php-warning": "تحذير: لا يتوفر [https://php.net/manual/en/book.intl.php امتداد PHP intl] للتعامل مع تطبيع يونيكود; حيث يتراجع لإبطاء تنفيذ Pure-Pure;\nإذا كنت تدير موقعا عالي الزيارات، فتجب عليك القراءة قليلا في [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations تطبيع يونيكود].", "config-unicode-update-warning": "تحذير: يستخدم الإصدار المثبت من برنامج تطبيع نظام يونيكود إصدارًا قديما من مكتبة [http://site.icu-project.org/ مشروع ICU];\nتجب عليك [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations الترقية] إذا كنت مهتما باستخدام يونيكود.", "config-no-db": "لا يمكن العثور على مشغل قاعدة بيانات مناسب! تحتاج إلى تثبيت مشغل قاعدة بيانات PHP، \n{{PLURAL:$2|نوع قاعدة البيانات التالي مدعوم|أنواع قاعدة البيانات التالية مدعومة}} البيانات التالية مدعومة: $1.\n\nإذا قمت بتجميع PHP بنفسك، فقم بتكوينها مع تمكين عميل قاعدة البيانات، على سبيل المثال، باستخدام ./configure --with-mysqli.\nإذا قمت بتثبيت PHP من حزمة Debian أو Ubuntu، فستحتاج أيضا إلى تثبيت، على سبيل المثال، حزمة php-mysql.", "config-outdated-sqlite": "تحذير: لديك SQLite $2، وهو أقل من الحد الأدنى المطلوب للنسخة $1، SQLite سوف يكون غير متوفر.", @@ -169,9 +173,6 @@ "config-db-web-no-create-privs": "الحساب الذي حددته لتركيب ليس لديه امتيازات كافية لإنشاء حساب.\nالحساب الذي حددته هنا موجود بالفعل.", "config-mysql-engine": "محرك التخزين", "config-mysql-innodb": "InnoDB (مستحسن)", - "config-mysql-myisam": "ماي إسام", - "config-mysql-myisam-dep": "تحذير: لقد اخترت MyISAM كمحرك تخزين لـMySQL، والذي لا يُنصَح باستخدامه مع ميدياويكي; لأنه:\n* بالكاد يدعم التزامن بسبب قفل الجدول \n* أكثر عرضة للفساد من المحركات الأخرى\n* لا يقوم الكود الأساسي لميدياويكي بمعالجة MyISAM دائما كما يجب\n\nإذا كان تثبيت MySQL يدعم InnoDB، فمن المستحسن جدا أن تختاره بدلا منه، \nإذا كان تثبيت MySQL لا يدعم InnoDB، فربما حان الوقت للترقية.", - "config-mysql-only-myisam-dep": "تحذير: MyISAMهو محرك التخزين الوحيد المتاح لـMySQL على هذا الجهاز، ولا يُنصَح باستخدامه مع ميدياويكي; لأنه:\n* بالكاد يدعم التزامن بسبب قفل الجدول \n* أكثر عرضة للفساد من المحركات الأخرى\n* لا يقوم الكود الأساسي لميدياويكي بمعالجة MyISAM دائما كما يجب\n\nتثبيت MySQL لا يدعم InnoDB; ربما حان الوقت للترقية.", "config-mysql-engine-help": "InnoDB هو دائما الخيار الأفضل; لأنه يحتوي على دعم تزامن جيد.\n\nقد يكون MyISAM أسرع في تثبيت المستخدم الفردي أو للقراءة فقط،\nتميل قواعد بيانات MyISAM للتلف أكثر من قواعد بيانات InnoDB.", "config-mssql-auth": "نوع الاستيثاق:", "config-mssql-install-auth": "حدد نوع المصادقة الذي سيتم استخدامه للاتصال بقاعدة البيانات أثناء عملية التثبيت. \nإذا حددت \"{{int:config-mssql-windowsauth}}\"، فسيتم استخدام بيانات اعتماد أي مستخدم يعمل عليه خادم الويب.", diff --git a/includes/installer/i18n/ast.json b/includes/installer/i18n/ast.json index 512f032824..6b2024f9f9 100644 --- a/includes/installer/i18n/ast.json +++ b/includes/installer/i18n/ast.json @@ -44,7 +44,7 @@ "config-help-restart": "¿Quies llimpiar tolos datos guardaos qu'introduxesti y reaniciar el procesu d'instalación?", "config-restart": "Sí, reanicialu", "config-welcome": "=== Comprobaciones del entornu ===\nAgora van facese unes comprobaciones básiques para ver si l'entornu ye afayadizu pa la instalación de MediaWiki.\nAlcuérdese d'incluir esta información si necesita encontu pa completar la instalación.", - "config-copyright": "=== Drechos d'autor y condiciones d'usu ===\n\n$1\n\nEsti programa ye software llibre; puedes redistribuilu y/o camudalu baxo les condiciones de la llicencia pública xeneral GNU tal como la publica la Free Software Foundation; versión 2 o (como prefieras) cualquier versión posterior.\n\nEsti programa distribúese cola esperanza de que pueda ser útil, pero ensin garantía denguna; nin siquiera la garantía implícita de comercialidá o \nadautación a un fin determináu.\nVer la Llicencia pública xeneral GNU pa más detalles.\n\nHabríes de tener recibío una copia de la llicencia pública xeneral GNU xunto con esti programa; sinón, escribi a la Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, o [https://www.gnu.org/copyleft/gpl.html lléila en llinia].", + "config-welcome-section-copyright": "=== Drechos d'autor y condiciones d'usu ===\n\n$1\n\nEsti programa ye software llibre; puedes redistribuilu y/o camudalu baxo les condiciones de la llicencia pública xeneral GNU tal como la publica la Free Software Foundation; versión 2 o (como prefieras) cualquier versión posterior.\n\nEsti programa distribúese cola esperanza de que pueda ser útil, pero ensin garantía denguna; nin siquiera la garantía implícita de comercialidá o \nadautación a un fin determináu.\nVer la Llicencia pública xeneral GNU pa más detalles.\n\nHabríes de tener recibío [$2 una copia de la llicencia pública xeneral GNU] xunto con esti programa; sinón, escribi a la Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, o [https://www.gnu.org/copyleft/gpl.html lléila en llinia].", "config-sidebar": "* [https://www.mediawiki.org/wiki/MediaWiki/gl Páxina principal de MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Guía del usuariu]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Guía del alministrador]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Entrugues frecuentes]\n----\n* Lléame\n* Notes de llanzamientu\n* Copia\n* Anovamientu", "config-env-good": "Comprobóse l'entornu.\nPues instalar MediaWiki.", "config-env-bad": "Comprobóse l'entornu.\nNun pues instalar MediaWiki.", @@ -154,7 +154,6 @@ "config-db-web-no-create-privs": "La cuenta qu'especificasti pa la instalación nun tien permisos abondo pa crear una cuenta.\nLa cuenta qu'especifiques equí yá tien d'esistir.", "config-mysql-engine": "Motor d'almacenamientu:", "config-mysql-innodb": "InnoDB (aconséyase)", - "config-mysql-myisam": "MyISAM", "config-mssql-auth": "Triba d'autenticación:", "config-mssql-sqlauth": "Autenticación de SQL Server", "config-mssql-windowsauth": "Autenticación de Windows", diff --git a/includes/installer/i18n/ba.json b/includes/installer/i18n/ba.json index e0d2bc9e65..23e53880b5 100644 --- a/includes/installer/i18n/ba.json +++ b/includes/installer/i18n/ba.json @@ -51,7 +51,7 @@ "config-help-restart": "Һеҙ үҙегеҙ индергән һәм һаҡланған әлеге мәғлүмәттәрҙе юйып, урынлаштырыуҙың яңы процессын ебәрергә теләйһегеҙме?", "config-restart": "Эйе, яңынан башларға", "config-welcome": "=== Даирәне тикшереү ===", - "config-copyright": "=== Авторлыҡ хоҡуҡтары һәм шарттар ===\n\n$1\n\nMediaWiki - ирекле программа тьәминәте. Һеҙ уны ирекле программа тьәминәте фонды баҫып сығарған GNU General Public License лицензия талаптарына ярашлы рәүештә тарата һәм/йәки үҙгәртә алаһығыҙ;икенсе версияһына йәки ниндәйҙә булһа һуңғы версияһына ярашлы рәүештә.\nMediaWiki - файҙалы булыу өмөтө менән таратыла, ләкин бер ниндәй ҙә гарантияларһыҙ, хатта күҙ уңында тотолған гарантияларһыҙ коммерция ҡимәтенән тыш йәки ниндәй ҙә булһа маҡсатҡа яраҡһыҙ . Ҡара. тулыраҡ мәғлүмәт алыу өсөн GNU General Public License лицезияһы. \nҺеҙ копияһын GNU General Public Licenseошо программа менән бергә алырға тейеш инегеҙ, әгәр алмаһағыҙ, Free Software Foundation, Inc. ошо адрес буйынса яҙығыҙ:51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA йәки [https://www.gnu.org/copyleft/gpl.html уны онлайнда уҡығыҙ].", + "config-welcome-section-copyright": "=== Авторлыҡ хоҡуҡтары һәм шарттар ===\n\n$1\n\nMediaWiki - ирекле программа тьәминәте. Һеҙ уны ирекле программа тьәминәте фонды баҫып сығарған GNU General Public License лицензия талаптарына ярашлы рәүештә тарата һәм/йәки үҙгәртә алаһығыҙ;икенсе версияһына йәки ниндәйҙә булһа һуңғы версияһына ярашлы рәүештә.\nMediaWiki - файҙалы булыу өмөтө менән таратыла, ләкин бер ниндәй ҙә гарантияларһыҙ, хатта күҙ уңында тотолған гарантияларһыҙ коммерция ҡимәтенән тыш йәки ниндәй ҙә булһа маҡсатҡа яраҡһыҙ . Ҡара. тулыраҡ мәғлүмәт алыу өсөн GNU General Public License лицезияһы. \nҺеҙ [$2 копияһын GNU General Public License]ошо программа менән бергә алырға тейеш инегеҙ, әгәр алмаһағыҙ, Free Software Foundation, Inc. ошо адрес буйынса яҙығыҙ:51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA йәки [https://www.gnu.org/copyleft/gpl.html уны онлайнда уҡығыҙ].", "config-sidebar": "* [https://www.mediawiki.org Сайт MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents/ru Ҡулланыусылар өсөн белешмә]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents/ru Администраторҙар өсөн белешмә]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/ru FAQ]\n----\n* Readme-файл\n* Сығарылыш тураһында мәғлүмәт\n* Лицензия\n* Яңыртыуҙар", "config-env-good": "Мөхитте тикшереү уңышлы тамамланды. MediaWiki урынлаштырырға мөмкин.", "config-env-bad": "Мөхит тикшерелде. Һеҙ MediaWiki урынлаштыра алмайһығыҙ.", @@ -163,9 +163,6 @@ "config-db-web-no-create-privs": "Ҡуйылыш өсөн күрһәтелгән иҫәп яҙмағыҙҙың уны барлыҡҡа килтереү өсөн етерлек хоҡуҡтары юҡ. \nКүрһәтелгән иҫәп яҙма бында булырға тейеш инде.", "config-mysql-engine": "Мәғлүмәт базаһы шыуҙырмаһы", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "\"Иғтибар\" Һеҙ MySQL мәғлүмәтен һаҡлау өсөн MyISAM механизмын һайланығыҙ. Түбәндәге сәбәптәр арҡаһында уны ҡулланыу тәҡдим ителмәй:\n* параллелизмда эшләп булмай;\n* башҡа механизмдар менән сағыштырғанда мәғлүмәттәр юғала;\n* MediaWiki коды MyISAM үҙенсәләген иҫәпкә алмай.\n\nҺеҙҙең MySQL InnoDB менән яраҡлы эшләһә ошо механизмды һайларға тәҡдим итебеҙ.\n\nҺеҙҙең MySQL InnoDB менән яраҡһыҙ эшләһә механизмды яңыртырға тәҡдим итебеҙ.", - "config-mysql-only-myisam-dep": "Иҫкәртеү: MyISAM — был компьютерҙә MySQL өсөн берҙән-бер асыҡ мәғлүмәттәр һаҡлау системаһы, һәм MediaWiki менән берлектә ҡулланырға рөхсәт ителмәй,сөнки:\n* таблицаларҙы блокировкалау һөҙөмтәһендә параллелизмды көсһөҙ тота;\n* башҡа системаларға ҡарағанда, ватылыуға күберәк дусар ителгән;\n* MediaWiki код базаһы MyISAM -ды ғәҙәттәгесә эшкәртеп бөтә алмай\nҺеҙҙең MySQL InnoDB -ды тотмай, бәлки, яңыртыу ваҡыты еткәндер.", "config-mysql-engine-help": "Параллель рәүештә яҡшыраҡ эшләгәне өсөн '''InnoDB''' өҫтөнлөрәк.\n\nБер ҡулланыусы йәки төҙәтеүҙәр әҙ булғанда вики өсөн '''MyISAM'''тың тиҙлеге шәберәк, әммә унда мәғлүмәт базаһы InnoDB-ҡа ҡарағанда йышыраҡ сафтан сыға.", "config-mssql-auth": "Аутентификация төрө :", "config-mssql-install-auth": "Ҡуйыу процесында мәғлүмәт базаһына инеү өсөн файҙаланылған төп нөсхәне тикшереү тибын һайлағыҙ. \n\nӘгәр «{{int:config-mssql-windowsauth}}» һайлаһығыҙ, ҡулланыусының веб-сервер эшләгән иҫәп яҙмаһы файҙаланыласаҡ.", diff --git a/includes/installer/i18n/be-tarask.json b/includes/installer/i18n/be-tarask.json index 80e063efff..c29f5f7ada 100644 --- a/includes/installer/i18n/be-tarask.json +++ b/includes/installer/i18n/be-tarask.json @@ -47,14 +47,18 @@ "config-help-restart": "Ці жадаеце выдаліць усе ўведзеныя зьвесткі і пачаць працэс усталяваньня зноў?", "config-restart": "Так, пачаць зноў", "config-welcome": "== Праверка асяродзьдзя ==\nЗараз будуць праведзеныя праверкі для запэўніваньня, што гэтае асяродзьдзе адпаведнае для ўсталяваньня MediaWiki.\nНе забудзьце далучыць гэтую інфармацыю, калі вам спатрэбіцца дапамога для завяршэньня ўсталяваньня.", - "config-copyright": "== Аўтарскае права і ўмовы ==\n\n$1\n\nThis 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.\n\nThis 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'''.\nSee the GNU General Public License for more details.\n\nYou 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. or [https://www.gnu.org/copyleft/gpl.html read it online].", - "config-sidebar": "* [https://www.mediawiki.org Хатняя старонка MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Даведка для ўдзельнікаў]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Даведка для адміністратараў]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Адказы на частыя пытаньні]\n----\n* Прачытайце\n* Паляпшэньні ў вэрсіі\n* Капіяваньне\n* Абнаўленьне", + "config-welcome-section-copyright": "== Аўтарскае права і ўмовы ==\n\n$1\n\nThis 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.\n\nThis 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'''.\nSee the GNU General Public License for more details.\n\nYou should have received [$2 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. or [https://www.gnu.org/copyleft/gpl.html read it online].", + "config-sidebar": "* [https://www.mediawiki.org Хатняя старонка MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Даведка для ўдзельнікаў]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Даведка для адміністратараў]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Адказы на частыя пытаньні]", + "config-sidebar-readme": "Прачытай мяне", + "config-sidebar-relnotes": "Заўвагі да выпуску", + "config-sidebar-license": "Капіяваньне", + "config-sidebar-upgrade": "Абнаўленьне", "config-env-good": "Асяродзьдзе было праверанае.\nВы можаце ўсталёўваць MediaWiki.", "config-env-bad": "Асяродзьдзе было праверанае.\nУсталяваньне MediaWiki немагчымае.", "config-env-php": "Усталяваны PHP $1.", "config-env-hhvm": "HHVM $1 усталяваная.", - "config-unicode-using-intl": "Выкарыстоўваецца [https://pecl.php.net/intl intl пашырэньне з PECL] для Unicode-нармалізацыі", - "config-unicode-pure-php-warning": "'''Папярэджаньне''': [https://pecl.php.net/intl Пашырэньне intl з PECL] — ня слушнае для Unicode-нармалізацыі, цяпер выкарыстоўваецца марудная PHP-рэалізацыя.\nКалі ў Вас сайт з высокай наведвальнасьцю, раім пачытаць пра [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode-нармалізацыю].", + "config-unicode-using-intl": "Выкарыстоўваецца [https://php.net/manual/en/book.intl.php PHP-пашырэньне intl] для Unicode-нармалізацыі.", + "config-unicode-pure-php-warning": "Папярэджаньне: [https://php.net/manual/en/book.intl.php PHP-пашырэньне intl] — ня слушнае для Unicode-нармалізацыі, цяпер выкарыстоўваецца марудная PHP-рэалізацыя.\nКалі ў вас сайт з высокай наведвальнасьцю, раім пачытаць пра [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode-нармалізацыю].", "config-unicode-update-warning": "'''Папярэджаньне''': усталяваная вэрсія бібліятэкі для Unicode-нармалізацыі выкарыстоўвае састарэлую вэрсію бібліятэкі з [http://site.icu-project.org/ праекту ICU].\nРаім [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations абнавіць], калі ваш сайт будзе працаваць з Unicode.", "config-no-db": "Немагчыма знайсьці адпаведны драйвэр базы зьвестак. Вам неабходна ўсталяваць драйвэр базы зьвестак для PHP.\n{{PLURAL:$2|Падтрымліваецца наступны тып базы|Падтрымліваюцца наступныя тыпы базаў}} зьвестак: $1.\n\nКалі вы скампілявалі PHP самастойна, зьмяніце канфігурацыю, каб уключыць кліента базы зьвестак, напрыклад, з дапамогай ./configure --with-mysqli.\nКалі вы ўсталявалі PHP з пакунку Debian або Ubuntu, тады вам трэба дадаткова ўсталяваць, напрыклад, пакунак php-mysql.", "config-outdated-sqlite": "Папярэджаньне: усталяваны SQLite $2, у той час, калі мінімальная сумяшчальная вэрсія — $1. SQLite ня будзе даступны.", @@ -165,9 +169,6 @@ "config-db-web-no-create-privs": "Рахунак, які Вы пазначылі для ўсталяваньня ня мае правоў для стварэньня рахунку.\nРахунак, які Вы пазначылі тут, мусіць ужо існаваць.", "config-mysql-engine": "Рухавік сховішча:", "config-mysql-innodb": "InnoDB (рэкамэндавана)", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "'''Папярэджаньне''': Вы выбралі MyISAM у якасьці рухавіка для захоўваньня зьвестак у MySQL, які не рэкамэндуецца да выкарыстаньня з MediaWiki па прычынах:\n* кепская падтрымка паралельнай апрацоўкі з-за таблічных блякаваньняў;\n* большая імавернасьць пашкоджаньня зьвестак у параўнаньні зь іншымі рухавікамі;\n* код MediaWiki не ва ўсіх выпадках улічвае асаблівасьці MyISAM.\n\nКалі Ваш MySQL-сэрвэр падтрымлівае InnoDB, вельмі рэкамэндуецца выкарыстаньне менавіта гэтага рухавіка.\nКалі MySQL-сэрвэр не падтрымлівае InnoDB, пэўна, настаў час абнавіць яго.", - "config-mysql-only-myisam-dep": "Папярэджаньне: MyISAM — адзіная даступная сыстэма захоўваньня зьвестак для MySQL на гэтым кампутары, яна не рэкамэндуецца для ўжываньня з MediaWiki, таму што:\n* слаба падтрымлівае паралельнасьць праз блякаваньне табліцаў\n* больш за іншыя сыстэмы схільная да пашкоджаньняў\n* кодавая база MediaWiki не заўсёды належна апрацоўвае MyISAM\n\nВашае ўсталяваньне MySQL не падтрымлівае InnoDB, магчыма, час для абнаўленьня.", "config-mysql-engine-help": "'''InnoDB''' — звычайна найбольш слушны варыянт, таму што добра падтрымлівае паралелізм.\n\n'''MyISAM''' можа быць хутчэйшай у вікі з адным удзельнікам, ці толькі для чытаньня.\nБазы зьвестак на MyISAM вядомыя тым, што ў іх зьвесткі шкодзяцца нашмат часьцей за InnoDB.", "config-mssql-auth": "Тып аўтэнтыфікацыі:", "config-mssql-install-auth": "Абярыце тып аўтэнтыфікацыі, які будзе выкарыстаны для злучэньня з базай зьвестак падчас працэсу ўсталяваньня.\nКалі вы абярэце «{{int:config-mssql-windowsauth}}», будуць выкарыстаныя ўліковыя зьвесткі карыстальніка, пад якім працуе вэб-сэрвэр.", diff --git a/includes/installer/i18n/bg.json b/includes/installer/i18n/bg.json index 8074a14721..de2f0cb047 100644 --- a/includes/installer/i18n/bg.json +++ b/includes/installer/i18n/bg.json @@ -47,17 +47,21 @@ "config-help-restart": "Необходимо е потвърждение за изтриване на всички въведени и съхранени данни и започване отначало на процеса по инсталация.", "config-restart": "Да, започване отначало", "config-welcome": "=== Проверка на условията ===\nЩе бъдат извършени основни проверки, които да установят дали условията са подходящи за инсталиране на МедияУики.\nАко е необходима помощ по време на инсталацията, резултатите от направените проверки трябва също да бъдат предоставени.", - "config-copyright": "=== Авторски права и условия ===\n\n$1\n\nТази програма е свободен софтуер, който може да се променя и/или разпространява според Общия публичен лиценз на GNU, както е публикуван от Free Software Foundation във версия на Лиценза 2 или по-късна версия.\n\nТази програма се разпространява с надеждата, че ще е полезна, но без каквито и да е гаранции; без дори косвена гаранция за продаваемост или пригодност за конкретна употреба .\nЗа повече подробности се препоръчва преглеждането на Общия публичен лиценз на GNU.\n\nКъм програмата трябва да е приложено копие на Общия публичен лиценз на GNU; ако не, можете да пишете на Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, или да [https://www.gnu.org/copyleft/gpl.html го прочетете онлайн].", - "config-sidebar": "* [https://www.mediawiki.org Сайт на МедияУики]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Наръчник на потребителя]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Наръчник на администратора]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ ЧЗВ]\n----\n* Документация\n* Бележки за версията\n* Авторски права\n* Обновяване", + "config-welcome-section-copyright": "=== Авторски права и условия ===\n\n$1\n\nТази програма е свободен софтуер, който може да се променя и/или разпространява според Общия публичен лиценз на GNU, както е публикуван от Free Software Foundation във версия на Лиценза 2 или по-късна версия.\n\nТази програма се разпространява с надеждата, че ще е полезна, но без каквито и да е гаранции; без дори косвена гаранция за продаваемост или пригодност за конкретна употреба .\nЗа повече подробности се препоръчва преглеждането на Общия публичен лиценз на GNU.\n\nКъм програмата трябва да е приложено [$2 копие на Общия публичен лиценз на GNU]; ако не, можете да пишете на Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, или да [https://www.gnu.org/copyleft/gpl.html го прочетете онлайн].", + "config-sidebar": "* [https://www.mediawiki.org Сайт на МедияУики]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Наръчник на потребителя]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Наръчник на администратора]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ ЧЗВ]", + "config-sidebar-readme": "Прочети ме", + "config-sidebar-relnotes": "Бележки за версията", + "config-sidebar-license": "Копиране", + "config-sidebar-upgrade": "Надграждане", "config-env-good": "Средата беше проверена.\nИнсталирането на МедияУики е възможно.", "config-env-bad": "Средата беше проверена.\nНе е възможна инсталация на МедияУики.", "config-env-php": "PHP $1 е инсталирано.", "config-env-hhvm": "HHVM $1 е инсталиран.", - "config-unicode-using-intl": "Използване на разширението [https://pecl.php.net/intl intl PECL] за нормализация на Уникод.", - "config-unicode-pure-php-warning": "Внимание: [https://pecl.php.net/intl Разширението intl PECL] не е налично за справяне с нормализацията на Уникод, превключване към по-бавното изпълнение на чист PHP.\nАко сайтът е с голям трафик, препоръчително е да се запознаете с [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations нормализацията на Уникод].", + "config-unicode-using-intl": "Използване на разширението [https://php.net/manual/en/book.intl.php intl PECL] за нормализация на Уникод.", + "config-unicode-pure-php-warning": "Внимание: [https://php.net/manual/en/book.intl.php Разширението intl PECL] не е налично за справяне с нормализацията на Уникод, превключване към по-бавното изпълнение на чист PHP.\nАко сайтът е с голям трафик, препоръчително е да се запознаете с [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations нормализацията на Уникод].", "config-unicode-update-warning": "Предупреждение: Инсталираната версия на Обвивката за нормализация на Unicode използва по-старата версия на библиотеката на [http://site.icu-project.org/ проекта ICU].\nНеобходимо е да [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations инсталирате по-нова версия], в случай че сте загрижени за използването на Unicode.", "config-no-db": "Не може да бъде открит подходящ драйвер за база данни! Необходимо е да инсталирате драйвер за база данни за PHP.\n{{PLURAL:$2|Поддържа се следния тип|Поддържат се следните типове}} бази от данни: $1.\n\nАко сами сте компилирали PHP, преконфигурирайте го с включен клиент за база данни, например чрез използване на ./configure --with-mysqli.\nАко сте инсталирали PHP от пакет за Debian или Ubuntu, необходимо е също така да инсталирате и модула php-mysql.", - "config-outdated-sqlite": "Внимание: имате инсталиран SQLite $1, а минималната допустима версия е $2. SQLite ще бъде недостъпна за ползване.", + "config-outdated-sqlite": "Внимание: имате инсталиран SQLite $2, а минималната допустима версия е $1. SQLite ще бъде недостъпна за ползване.", "config-no-fts3": "Предупреждение: SQLite е компилирана без [//sqlite.org/fts3.html модула FTS3], затова възможностите за търсене няма да са достъпни.", "config-pcre-old": "Фатална грешка: Изисква се PCRE версия $1 или по-нова.\nИзпълнимият файл на PHP е свързан с PCRE версия $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/Повече информация за PCRE].", "config-pcre-no-utf8": "Фатално: Модулът PCRE на PHP изглежда е компилиран без поддръжка на PCRE_UTF8.\nЗа да функционира правилно, МедияУики изисква поддръжка на UTF-8.", @@ -162,9 +166,6 @@ "config-db-web-no-create-privs": "Посочената сметка за инсталацията не разполага с достатъчно права за създаване на нова сметка.\nНеобходимо е посочената сметка вече да съществува.", "config-mysql-engine": "Хранилище на данни:", "config-mysql-innodb": "InnoDB (препоръчително)", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "Внимание: Избрана е MyISAM като система за складиране в MySQL, която не се препоръчва за използване с МедияУики, защото:\n* почти не поддържа паралелност заради заключване на таблиците\n* е по-податлива на повреди в сравнение с други системи\n* кодът на МедияУики не винаги поддържа MyISAM коректно\n\nАко инсталацията на MySQL поддържа InnoDB, силно е препоръчително да се използва тя.\nАко инсталацията на MySQL не поддържа InnoDB, вероятно е време за обновяване.", - "config-mysql-only-myisam-dep": "Внимание: MyISAM e единственият наличен на тази машина тип на таблиците за MySQL и не е препоръчителен за употреба при МедияУики защото:\n* има слаба поддръжка на конкурентност на заявките, поради закючването на таблиците\n* е много по-податлив на грешки в базите от данни от другите типове таблици\n* кодът на МедияУики не винаги работи с MyISAM както трябва\n\nВашият MySQL не поддържа InnoDB, така че може би е дошло време за актуализиране.", "config-mysql-engine-help": "InnoDB почти винаги е най-добрата възможност заради навременната си поддръжка.\n\nMyISAM може да е по-бърза при инсталации с един потребител или само за четене.\nБазите от данни MyISAM се повреждат по-често от InnoDB.", "config-mssql-auth": "Тип на удостоверяването:", "config-mssql-install-auth": "Изберете начин за удостоверяване, който ще бъде използван за връзка с базата от данни по време на инсталацията.\nАко изберете \"{{int:config-mssql-windowsauth}}\", ще се използват идентификационните данни на потребителя под който работи уеб сървъра.", @@ -305,6 +306,7 @@ "config-install-done": "Поздравления!\nИнсталирането на МедияУики приключи успешно.\n\nИнсталаторът създаде файл LocalSettings.php.\nТой съдържа всичката необходима основна конфигурация на уикито.\n\nНеобходимо е той да бъде изтеглен и поставен в основната директория на уикито (директорията, в която е и index.php). Изтеглянето би трябвало да започне автоматично.\n\nАко изтеглянето не започне автоматично или е било прекратено, файлът може да бъде изтеглен чрез щракване на препратката по-долу:\n\n$3\n\nЗабележка: Ако това не бъде извършено сега, генерираният конфигурационен файл няма да е достъпен на по-късен етап ако не бъде изтеглен сега или инсталацията приключи без изтеглянето му.\n\nКогато файлът вече е в основната директория, [$2 уикито ще е достъпно на този адрес].", "config-install-done-path": "Поздравления!\nИнсталирането на МедияУики приключи успешно.\n\nИнсталаторът създаде файл LocalSettings.php.\nТой съдържа всички ваши настройки.\n\nНеобходимо е той да бъде изтеглен и поставен в $4. Изтеглянето би трябвало да започне автоматично.\n\nАко изтеглянето не започне автоматично или е било прекратено, файлът може да бъде изтеглен чрез щракване на препратката по-долу:\n\n$3\n\nЗабележка: Ако това не бъде направено сега, генерираният конфигурационен файл няма да е достъпен на по-късен етап ако не бъде изтеглен сега или инсталацията приключи без изтеглянето му.\n\nКогато файлът вече е в основната директория, [$2 уикито ще е достъпно на този адрес].", "config-install-success": "МедияУики беше успешно инсталиран. Можете да посетите <$1$2> за да видите Вашето уики.\n\nАко имате въпроси, вижте списъка с често задавани въпроси:\n или използвайте някой от форумите за поддръжка на тази страница.", + "config-install-db-success": "Базата от данни е успешно настроена", "config-download-localsettings": "Изтегляне на LocalSettings.php", "config-help": "помощ", "config-help-tooltip": "щракнете за разгръщане", diff --git a/includes/installer/i18n/bn.json b/includes/installer/i18n/bn.json index 2392f732c7..0debe4a0ed 100644 --- a/includes/installer/i18n/bn.json +++ b/includes/installer/i18n/bn.json @@ -87,7 +87,6 @@ "config-regenerate": "LocalSettings.php পুনরূত্পাদিত করুন →", "config-mysql-engine": "সংগ্রহস্থল ইঞ্জিন:", "config-mysql-innodb": "InnoDB (সুপারিশকৃত)", - "config-mysql-myisam": "MyISAM", "config-mssql-windowsauth": "উইন্ডোজ প্রমাণীকরণ", "config-site-name": "উইকির নাম:", "config-site-name-blank": "একটি সাইটের নাম প্রবেশ করান।", diff --git a/includes/installer/i18n/br.json b/includes/installer/i18n/br.json index 91f9bdba5d..de6655b3e7 100644 --- a/includes/installer/i18n/br.json +++ b/includes/installer/i18n/br.json @@ -46,7 +46,7 @@ "config-help-restart": "Ha c'hoant hoc'h eus da ziverkañ an holl roadennoù hoc'h eus ebarzhet ha da adlañsañ an argerzh staliañ ?", "config-restart": "Ya, adloc'hañ anezhañ", "config-welcome": "=== Gwiriadennoù a denn d'an endro ===\nRekis eo un nebeud gwiriadennoù diazez da welet hag azas eo an endro evit gallout staliañ MediaWiki.\nHo pet soñj merkañ disoc'hoù ar gwiriadennoù-se m'ho pez ezhomm skoazell e-pad ar staliadenn.", - "config-copyright": "=== Gwiriañ aozer ha Termenoù implijout ===\n\n$1\n\nUr meziant frank eo ar programm-mañ; gallout a rit skignañ anezhañ ha/pe kemmañ anezhañ dindan termenoù ar GNU Aotre-implijout Foran Hollek evel m'emañ embannet gant Diazezadur ar Meziantoù Frank; pe diouzh stumm 2 an aotre-implijout, pe (evel mar karit) diouzh ne vern pe stumm nevesoc'h.\n\nIngalet eo ar programm gant ar spi e vo talvoudus met n'eus '''tamm gwarant ebet'''; hep zoken gwarant empleg ar '''varc'hadusted''' pe an '''azaster ouzh ur pal bennak'''. Gwelet ar GNU Aotre-Implijout Foran Hollek evit muioc'h a ditouroù.\n\nSañset oc'h bezañ resevet un eilskrid eus ar GNU Aotre-implijout Foran Hollek a-gevret gant ar programm-mañ; ma n'hoc'h eus ket, skrivit da Diazezadur ar Meziantoù Frank/Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, SUA pe [https://www.gnu.org/licenses/old-licenses/gpl-2.0.html lennit anezhañ enlinenn].", + "config-welcome-section-copyright": "=== Gwiriañ aozer ha Termenoù implijout ===\n\n$1\n\nUr meziant frank eo ar programm-mañ; gallout a rit skignañ anezhañ ha/pe kemmañ anezhañ dindan termenoù ar GNU Aotre-implijout Foran Hollek evel m'emañ embannet gant Diazezadur ar Meziantoù Frank; pe diouzh stumm 2 an aotre-implijout, pe (evel mar karit) diouzh ne vern pe stumm nevesoc'h.\n\nIngalet eo ar programm gant ar spi e vo talvoudus met n'eus '''tamm gwarant ebet'''; hep zoken gwarant empleg ar '''varc'hadusted''' pe an '''azaster ouzh ur pal bennak'''. Gwelet ar GNU Aotre-Implijout Foran Hollek evit muioc'h a ditouroù.\n\nSañset oc'h bezañ resevet [$2 un eilskrid eus ar GNU Aotre-implijout Foran Hollek] a-gevret gant ar programm-mañ; ma n'hoc'h eus ket, skrivit da Diazezadur ar Meziantoù Frank/Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, SUA pe [https://www.gnu.org/licenses/old-licenses/gpl-2.0.html lennit anezhañ enlinenn].", "config-sidebar": "* [https://www.mediawiki.org MediaWiki degemer]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Sturlevr an implijerien]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Sturlevr ar verourien]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAG]\n----\n* Lennit-me\n* Notennoù embann\n* Oc'h eilañ\n* O hizivaat", "config-env-good": "Gwiriet eo bet an endro.\nGallout a rit staliañ MediaWiki.", "config-env-bad": "Gwiriet eo bet an endro.\nNe c'hallit ket staliañ MediaWiki.", @@ -162,9 +162,6 @@ "config-db-web-no-create-privs": "Ar gont ho peus diferet evit ar staliañ n'he deus ket gwirioù a-walc'h evit krouiñ ur gont.\nRet eo d'ar gont diferet amañ bezañ anezhi dija.", "config-mysql-engine": "Lusker stokañ :", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "Diwallit : Diuzet eo bet ganeoc'h MyISAM evel keflusker stokañ evit MySQL, ar pezh n'eo ket erbedet evit implijout gant MediaWiki, rak :\n* a-boan m'eo skoret gantañ ober meur a dra war un dro peogwir eo prennet an taolennoù\n* techetoc'h eo d'ar gwastoù eget kefluskerioù all\n* kod diazez MediaWiki n'eo ket atav embreget MyISAM gantañ evel m'eo dleet\n\nM'eo skoret InnoDB gant ho staliadur MySQL, ez eo kuzuliet c'hwek deoc'h dibab hennezh kentoc'h.\nMa n'eo ket skoret InnoDB gant ho staliadur MySQL, e c'hallfe bezañ poent deoc'h ober un hizivadenn.", - "config-mysql-only-myisam-dep": "Taolit evezh : MyISAM eo ar c'heflusker stokañ nemetañ a c'haller ober gantañ war ar mekanik-mañ evit MySQL, padal n'eo ket erbedet e implij gant MediaWiki, rak :\n* a-boan ma skor ar c'hevezerezh abalamour m'eo prennet an taolennoù\n* aesoc'h eo e wastañ eget kefluskerioù all\n* n'eo ket atav embreget MyIsam evel ma tlefe bezañ gant kod diazez MediaWiki\n\nN'eo ket skoret InnoDB gant ho staliadur MySQL. Poent eo hizivaat anezhañ marteze.", "config-mysql-engine-help": "InnoDB eo an dibab gwellañ koulz lavaret atav, kemer a ra e kont ar monedoù kevezus.\n\nMyISAM a c'hall bezañ fonnusoc'h evit ar staliadurioù unpost pe ar re lenn hepken.\nDiazoù roadennoù MyISAM zo techet da vezañ gwastet aliesoc'h eget re InnoDB.", "config-mssql-auth": "Seut anaoudadur :", "config-mssql-install-auth": "Diuzañ ar seurt dilesa a vo implijet evit kevreañ ouzh an diaz roadennoù e-pad ar staliañ.\nMa tibabit \"{{int:config-mssql-windowsauth}}\", e vo implijet titouroù anaout an implijer a laka ar servijer da dreiñ.", diff --git a/includes/installer/i18n/bs.json b/includes/installer/i18n/bs.json index e6118f8803..50421abb9c 100644 --- a/includes/installer/i18n/bs.json +++ b/includes/installer/i18n/bs.json @@ -104,7 +104,6 @@ "config-db-web-create": "Napravi račun ako već ne postoji", "config-mysql-engine": "Skladišni pogon:", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", "config-mssql-auth": "Vrsta autentifikacije:", "config-site-name": "Ime wikija:", "config-site-name-blank": "Upišite ime sajta.", diff --git a/includes/installer/i18n/bto.json b/includes/installer/i18n/bto.json index 6fb1bbe169..19d6e45b10 100644 --- a/includes/installer/i18n/bto.json +++ b/includes/installer/i18n/bto.json @@ -38,7 +38,6 @@ "config-header-oracle": "Oracle settings", "config-header-mssql": "Microsoft SQL Server settings", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", "config-site-name": "Ngaran ka wiki", "config-site-name-blank": "Ibutang a ngaran ka site.", "config-project-namespace": "Bibutangan ka proyekto:", diff --git a/includes/installer/i18n/ca.json b/includes/installer/i18n/ca.json index c9abc94cf3..d49fd9fb8d 100644 --- a/includes/installer/i18n/ca.json +++ b/includes/installer/i18n/ca.json @@ -51,24 +51,30 @@ "config-help-restart": "Voleu esborrar totes les dades que heu introduït i tornar a començar el procés d'instal·lació?", "config-restart": "Sí, torna a començar", "config-welcome": "=== Comprovacions de l'entorn ===\nS'efectuaran comprovacions bàsiques per veure si l'entorn és adequat per a la instal·lació del MediaWiki.\nRecordeu d'incloure aquesta informació si heu de demanar ajuda sobre com completar la instal·lació.", - "config-copyright": "=== Drets d'autor i condicions ===\n\n$1\n\nAquest programa és de programari lliure; podeu redistribuir-lo i/o modificar-lo sota les condicions de la Llicència Pública General GNU com es publicada per la Free Software Foundation; qualsevol versió 2 de la llicència, o (opcionalment) qualsevol versió posterior.\n\nAquest programa és distribueix amb l'esperança que serà útil, però sense cap garantia; sense ni tan sols la garantia implícita de \ncomerciabilitat o idoneïtat per a un propòsit particular.\nConsulteu la Llicència Pública General GNU, per a més detalls.\n\nHauríeu d'haver rebut una còpia de la Llicència Pública General GNU amb aquest programa; si no, escriviu a la Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA o [https://www.gnu.org/copyleft/gpl.html per llegir-lo en línia].", + "config-welcome-section-copyright": "=== Drets d'autor i condicions ===\n\n$1\n\nAquest programa és de programari lliure; podeu redistribuir-lo i/o modificar-lo sota les condicions de la Llicència Pública General GNU com es publicada per la Free Software Foundation; qualsevol versió 2 de la llicència, o (opcionalment) qualsevol versió posterior.\n\nAquest programa és distribueix amb l'esperança que serà útil, però sense cap garantia; sense ni tan sols la garantia implícita de \ncomerciabilitat o idoneïtat per a un propòsit particular.\nConsulteu la Llicència Pública General GNU, per a més detalls.\n\nHauríeu d'haver rebut [$2 una còpia de la Llicència Pública General GNU] amb aquest programa; si no, escriviu a la Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA o [https://www.gnu.org/copyleft/gpl.html per llegir-lo en línia].", "config-sidebar": "* [https://www.mediawiki.org la Pàgina d'inici]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Guia de l'usuari]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Guia de l'administrador]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ PMF]\n----\n* Llegeix-me\n* Notes de la versió\n* Còpia\n* Actualització", + "config-sidebar-readme": "Llegeix-me", + "config-sidebar-relnotes": "Notes de la versió", + "config-sidebar-license": "Còpia", + "config-sidebar-upgrade": "Actualització", "config-env-good": "S'ha comprovat l'entorn.\nPodeu instal·lar el MediaWiki.", "config-env-bad": "S'ha comprovat l'entorn.\nNo podeu instal·lar el MediaWiki.", "config-env-php": "El PHP $1 està instal·lat.", "config-env-hhvm": "L’HHVM $1 és instal·lat.", - "config-unicode-using-intl": "S'utilitza l'[https://pecl.php.net/intl extensió intl PECL] per a la normalització de l'Unicode.", - "config-unicode-pure-php-warning": "Avís: L'[https://pecl.php.net/intl extensió intl PECL] no és disponible per gestionar la normalització d'Unicode. Es reverteix a una implementació més lenta en PHP pur.\nSi administreu un lloc web amb molt de trànsit, hauríeu de consultar la guia de [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalització d'Unicode].", + "config-unicode-using-intl": "S'utilitza l'[https://php.net/manual/en/book.intl.php extensió intl de PHP] per a la normalització de l'Unicode.", + "config-unicode-pure-php-warning": "Avís: L'[https://php.net/manual/en/book.intl.php extensió intl de PHP] no és disponible per gestionar la normalització d'Unicode. Es reverteix a una implementació més lenta en PHP pur.\nSi administreu un lloc web amb molt de trànsit, hauríeu de consultar la guia de [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalització d'Unicode].", "config-unicode-update-warning": "Avís: La versió instal·lada del contenidor de normalització d'Unicode utilitza una versió antiga de la biblioteca [http://site.icu-project.org/ del projecte ICU].\nHauríeu [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations d'actualitzar-la] si us importa poder utilitzar Unicode.", "config-no-db": "No s'ha pogut trobar un controlador adequat per a la base de dades. Instal·leu-ne un per al PHP.\nHi ha suport per {{PLURAL:$2|al tipus de base de dades següent|als tipus de base de dades següents}}: $1\n\nSi heu compilat el PHP manualment, torneu a configurar-lo amb un client de base de dades habilitat, per exemple fent servir ./configure --with-mysqli.\nSi heu instal·lat el PHP d'un paquet de Debian o Ubuntu, també cal que instal·leu, per exemple, el paquet php-mysql.", - "config-outdated-sqlite": "Avís: teniu el SQLite $1, que és menor que la versió mínima necessària $2. SQLite no estarà disponible.", + "config-outdated-sqlite": "Avís: teniu el SQLite $2, que és menor que la versió mínima necessària $1. SQLite no estarà disponible.", "config-no-fts3": "Avís: SQLite està compilat sense el [//sqlite.org/fts3.html mòdul FTS3], per tant les funcionalitats de cerca no estaran disponibles en aquesta instal·lació.", "config-pcre-old": "Error fatal: Cal el PCRE $1 o superior.\nEl binari PHP que utilitzeu està enllaçat amb el PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Més informació].", + "config-pcre-no-utf8": "Fatal: El mòdul PCRE de PHP sembla que no va compilar-se per funcionar amb PCRE_UTF8.\nMediaWiki necessita que UTF-8 funcioni correctament.", "config-memory-raised": "El memory_limit del PHP és $1 i s'ha aixecat a $2.", "config-memory-bad": "Avís: El memory_limit del PHP és $1.\nAixò és probablement massa baix.\nLa instal·lació pot fallar!", "config-apc": "L'[https://www.php.net/apc APC] està instal·lat", "config-apcu": "L'[https://www.php.net/apcu APCu] està instal·lat", "config-wincache": "[https://www.iis.net/downloads/microsoft/wincache-extension WinCache] està instal·lat", + "config-no-cache-apcu": "Avís: no s'ha pogut trobar [https://www.php.net/apcu APCu] o [https://www.iis.net/downloads/microsoft/wincache-extension WinCache].\nNo s'habilitarà la memòria cau d'objectes.", "config-diff3-bad": "No s'ha trobat el GNU diff3. Podeu ignorar-ho per ara, però us podeu trobar amb conflictes d'edició més habitualment.", "config-git": "S'ha trobat el programari de control de versions Git: $1.", "config-git-bad": "No s'ha trobat el programari de control de versions Git. Podeu ignorar-ho per ara, però la pàgina Especial:Versió no mostrarà els resums de publicacions.", @@ -80,6 +86,7 @@ "config-using-server": "S'utilitza el nom del servidor «$1».", "config-using-uri": "S'utilitza l'URL del servidor «$1$2».", "config-uploads-not-safe": "Avís: El directori de càrregues per defecte $1 és vulnerable a l'execució d'scripts arbitraris.\nEncara que el MediaWiki comprova tots els fitxers que es carreguen davant d'amenaces de seguretat, és molt recomanable [https://www.mediawiki.org/ wiki/Special:MyLanguage/Manual:Security#Upload_security tancar aquesta vulnerabilitat de seguretat] abans d'habilitar les càrregues.", + "config-no-cli-uploads-check": "Avís: no s'ha comprovat el directori per defecte per a càrregues ($1$) per vulnerabilitats en l'execució arbitrària durant la instal·lació amb la línia d'ordres.", "config-brokenlibxml": "El vostre sistema té una combinació de versions de PHP i libxml2 que són problemàtiques i que poden causar corrupció de dades no aparent a MediaWiki i a altres aplicacions web.\nActualitzeu-vos a libxml2 2.7.3 o superior ([https://bugs.php.net/bug.php?id=45996 informe d'error al projecte PHP]).\nS'ha interromput la instal·lació.", "config-db-type": "Tipus de base de dades:", "config-db-host": "Servidor de la base de dades:", @@ -106,6 +113,7 @@ "config-db-schema-help": "Aquest esquema normalment ja serveix.\nNomés canvieu-lo si sabeu què us feu.", "config-pg-test-error": "No es pot connectar a la base de dades '''$1''': $2", "config-sqlite-dir": "Directori de dades de l'SQLite", + "config-sqlite-dir-help": "L'SQLite emmagatzema totes les dades en un únic fitxer.\n\nEl directori que proporcioneu ha de ser escrivible pel servidor durant la instal·lació.\n\nNo hauria de ser accessible des del web. Aquest és el motiu perquè no el posem on són els fitxers PHP.\n\nL'instal·lador escriurà un fitxer .htaccess al mateix temps, però si això fallés, seria possible que s'accedís a la base de dades crua.\nAixò inclou dades d'usuari crues (adreces electròniques, contrasenyes en resum) així com revisions eliminades i altres dades restringida en el wiki.\n\nConsidereu posar la base de dades en algun altre lloc tot plegat, per exemple a /var/lib/mediawiki/yourwiki.", "config-oracle-def-ts": "Espai de taules per defecte:", "config-oracle-temp-ts": "Espai de taules temporal:", "config-type-mysql": "MariaDB, MySQL o compatible", @@ -149,9 +157,6 @@ "config-db-web-no-create-privs": "El compte que heu especificat a la instal·lació no té suficients privilegis per crear un compte. El compte que especifiqueu aquí ja ha d'existir.", "config-mysql-engine": "Motor d'emmagatzemament:", "config-mysql-innodb": "InnoDB (recomanat)", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "Avís: Heu seleccionat MyISAM com a motor d'emmagatzemament de MySQL, que no és recomanat per utilitzar amb MediaWiki, perquè:\n* no té un bon suport de concurrència a causa del blocatge de les taules\n* té més tendència a corrompre's que altres motors\n* el codi base de MediaWiki no sempre gestiona MyISAM adequadament\n\nSi la vostra instal·lació de MySQL permet l'ús d'InnoDB, és molt més recomanable que l'utilitzeu.\nSi, per contra, no el permet. Potser val la pena que considereu actualitzar-la.", - "config-mysql-only-myisam-dep": "Avís: MyISAM és l'únic motor d'emmagatzemament de MySQL d'aquesta màquina, que no és recomanat per utilitzar amb MediaWiki, perquè:\n* no té un bon suport de concurrència a causa del blocatge de les taules\n* té més tendència a corrompre's que altres motors\n* el codi base de MediaWiki no sempre gestiona MyISAM adequadament\n\nLa vostra instal·lació de MySQL no permet l'ús d'InnoDB. Potser val la pena que considereu actualitzar-la.", "config-mysql-engine-help": "InnoDB és gairebé sempre la millor opció perquè té una bona implementació de concurrència.\n\nMyISAM pot ser més ràpid en instal·lacions d'un únic usuari o de només lectura.\nLes bases de dades MyISAM tendeixen a corrompre's més sovint que les InnoDB.", "config-mssql-auth": "Tipus d'autenticació:", "config-mssql-install-auth": "Seleccioneu el tipus d'autenticació que s'utilitzarà per connectar-se amb el servidor de base de dades durant el procés d'instal·lació.\nSi seleccioneu «{{int:config-mssql-windowsauth}}», s'utilitzaran les credencials de l'usuari amb què s'executa el servidor web.", @@ -184,6 +189,7 @@ "config-admin-error-password": "S'ha produït un error intern en definir una contrasenya per a l'administrador «$1»:
$2
", "config-admin-error-bademail": "Heu introduït una adreça electrònica no vàlida.", "config-subscribe": "Subscriu a la [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce llista de correu d'anunci de noves versions].", + "config-subscribe-noemail": "Us heu provat de subscriure a la llista de correu d'anuncis de noves versions sense proporcionar-hi una adreça electrònica.\nProporcioneu-ne una si voleu subscriure-us a la llista de correu electrònic.", "config-pingback": "Comparteix dades d'aquesta instal·lació amb els desenvolupadors de MediaWiki.", "config-almost-done": "Gairebé ja heu acabat!\nPodeu ometre el que queda de la configuració i procedir amb la instal·lació del wiki.", "config-optional-continue": "Fes-me més preguntes.", @@ -202,6 +208,7 @@ "config-license-gfdl": "GNU Free Documentation License 1.3 o posterior", "config-license-pd": "Domini públic", "config-license-cc-choose": "Selecció d'una llicència personalitzada de Creative Commons", + "config-license-help": "Molts wikis públics posen totes llurs contribucions sota una [https://freedomdefined.org/Definició llicència lliure].\nAixò ajuda a crear un sentit de propietat de comunitat i anima les contribucions de llarg termini.\nNo és generalment necessari per a un wiki privat o corporatiu.\n\nSi voleu ser capaç d'utilitzar text de la Viquipèdia, i voleu que la Viquipèdia pugui acceptar text copiat del vostre wiki, hauríeu de triar {{int:config-license-cc-by-sa}}.\n\nLa Viquipèdia abans utilitzava la Llicència de documentació lliure de GNU.\nEl GFDL és una llicència vàlida, però difícil d'entendre.\nÉs també difícil reutilitzar contingut sota la GFDL.", "config-email-settings": "Paràmetres del correu electrònic", "config-enable-email": "Habilita el correu sortint", "config-enable-email-help": "Si voleu que el correu electrònic funcioni, cal configurar [https://www.php.net/manual/en/mail.configuration.php PHP's els paràmetres de correu] correctament.\nSi no voleu cap funcionalitat de correu, podeu inhabilitar-ho.", @@ -214,12 +221,14 @@ "config-email-auth": "Habilita l'autenticació per correu electrònic", "config-email-auth-help": "Si s'habilita l'opció, els usuaris hauran de confirmar llur adreça electrònica utilitzant un enllaç que els enviarem quan la defineixin o la canviïn.\nNomés les adreces electròniques autenticades poden rebre correus d'altres usuaris o canviar les notificacions de correu.\nDefinir aquesta opció és recomanat per a wikis públics per tal d'evitar els possibles abusos de l'ús del correu.", "config-email-sender": "Adreça electrònica de retorn:", + "config-email-sender-help": "Introduïu una adreça electrònica per utilitzar-la com a adreça de retorn dels missatges electrònics de sortida.\nAquí és on s'enviaran els missatges que no arribin a lloc.\nMolts servidors de correu electrònic necessiten com a mínim que la part del nom de domini sigui vàlida.", "config-upload-settings": "Imatges i càrregues de fitxers", "config-upload-enable": "Habilita la càrrega de fitxers", "config-upload-help": "Les càrregues de fitxers potencialment exposen el vostre servidor a riscos de seguretat.\nPer a més informació, llegiu la [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security secció de seguretat] del manual.\n\nPer habilitar les càrregues de fitxer, canvieu el mode del subdirectori images del directori arrel de MediaWiki per tal que el servidor web pugui escriure-hi.\nA continuació, habiliteu-ne l'opció.", "config-upload-deleted": "Directori pels arxius suprimits:", "config-upload-deleted-help": "Trieu un directori per a arxivar els fitxers suprimits.\nIdealment no hauria de ser accessible des del web.", "config-logo": "URL del logo:", + "config-logo-help": "L'aparença per defecte de MediaWiki inclou un espai per a un logotip de 135x160 píxels sobre el menú de la barra lateral.\nCarregueu una imatge de la mida apropiada i introduïu un URL aquí.\n\nPodeu utilitzar $wgStylePath o $wgScriptPath si el vostre logotip es relatiu a aquests camins.\n\nSi no voleu cap logotip, deixeu el quadre en blanc.", "config-instantcommons": "Habilita Instant Commons", "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons Instant Commons] és una característica que permet que els wikis utilitzin imatges, sons i altres fitxers multimèdies que es troben al lloc web de [https://commons.wikimedia.org/ Wikimedia Commons].\nPer a això, cal que el MediaWiki tingui accés a Internet.\n\nPer a més informació d'aquesta característica, amb instuccions de com definir altres wikis apart de Wikimedia Commons, consulteu [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos el manual].", "config-cc-error": "El selector de llicència Creative Commons no ha donat cap resultat.\nIntroduïu la llicència manualment.", @@ -240,11 +249,11 @@ "config-extensions": "Extensions", "config-extensions-help": "Les extensions que es llisten a dalt s'han detecta en el directori ./extensions.\n\nPoden necessitar configuració addicional, però ja podeu habilitar-les.", "config-skins": "Aparences", - "config-skins-help": "S'han detectat els temes llistats a dalt en el directori ./skins. Heu d'habilitar-ne com a mínim un i trieu-ne el predeterminat.", - "config-skins-use-as-default": "Utilitza aquest tema per defecte", - "config-skins-missing": "No s'ha trobat cap tema; MediaWiki utilitzarà el tema per defecte fins que hi instal·leu alguns adequats.", + "config-skins-help": "S'han detectat les aparences llistades a dalt en el directori ./skins. Heu d'habilitar-ne com a mínim un i trieu-ne el predeterminat.", + "config-skins-use-as-default": "Utilitza aquesta aparença per defecte", + "config-skins-missing": "No s'ha trobat cap aparença; MediaWiki utilitzarà l'aparença per defecte fins que hi instal·leu algunes adequades.", "config-skins-must-enable-some": "Heu de triar com a mínim un tema per habilitar.", - "config-skins-must-enable-default": "Cal habilitar el tema triat per defecte.", + "config-skins-must-enable-default": "Cal habilitar l'aparença triada per defecte.", "config-install-alreadydone": "Avís: Sembla que ja havíeu instal·lat MediaWiki i esteu provant d'instal·lar-lo de nou.\nProcediu a la pàgina següent.", "config-install-begin": "En fer clic a «{{int:config-continue}}» s’iniciarà la instal·lació del MediaWiki. Si encara voleu fer canvis, feu clic a «{{int:config-back}}».", "config-install-step-done": "fet", diff --git a/includes/installer/i18n/ce.json b/includes/installer/i18n/ce.json index be482e9c5d..baba3e2ec5 100644 --- a/includes/installer/i18n/ce.json +++ b/includes/installer/i18n/ce.json @@ -27,7 +27,7 @@ "config-page-copying": "Лицензи", "config-page-upgradedoc": "Карлаяккхар", "config-page-existingwiki": "Йолуш йолу вики", - "config-copyright": "=== Авторан бакъонаш а хьал а ===\n\n$1\nMediaWiki ю маьрша программин латораг, шу йиш ю фондас арахецна йолу GNU General Public License лицензица и яржо я хийца а.\n\nMediaWiki яржош ю и шуна пайдане хир яц те аьлла, амма цхьа юкъарахилар доцуш. Хь. кхин. лицензи мадарра GNU General Public License .\n\nШоьга кхача езаш яра [{{SERVER}}{{SCRIPTPATH}}/COPYING копи GNU General Public License] хӀокху программица, кхаьчна яцахь язъе Free Software Foundation, Inc., адрес тӀе: 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA я [https://www.gnu.org/licenses/old-licenses/gpl-2.0.html еша и онлайнехь].", + "config-welcome-section-copyright": "=== Авторан бакъонаш а хьал а ===\n\n$1\nMediaWiki ю маьрша программин латораг, шу йиш ю фондас арахецна йолу GNU General Public License лицензица и яржо я хийца а.\n\nMediaWiki яржош ю и шуна пайдане хир яц те аьлла, амма цхьа юкъарахилар доцуш. Хь. кхин. лицензи мадарра GNU General Public License .\n\nШоьга кхача езаш яра [$2 копи GNU General Public License] хӀокху программица, кхаьчна яцахь язъе Free Software Foundation, Inc., адрес тӀе: 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA я [https://www.gnu.org/licenses/old-licenses/gpl-2.0.html еша и онлайнехь].", "config-no-fts3": "'''Тергам бе''': SQLite гулйина хуттург йоцуш [//sqlite.org/fts3.html FTS3] — лахар болхбеш хир дац оцу бухца.", "config-no-cli-uri": "'''ДӀахьедар''': --scriptpath параметр язйина яц, иза Ӏадйитаран кепаца лелош ю: $1 .", "config-db-name": "Хаамийн базан цӀе:", diff --git a/includes/installer/i18n/cs.json b/includes/installer/i18n/cs.json index 8f62c4f631..aef912c462 100644 --- a/includes/installer/i18n/cs.json +++ b/includes/installer/i18n/cs.json @@ -12,7 +12,8 @@ "Seb35", "Ilimanaq29", "Dvorapa", - "Patriccck" + "Patriccck", + "Tchoř" ] }, "config-desc": "Instalační program pro MediaWiki", @@ -52,14 +53,18 @@ "config-help-restart": "Chcete smazat všechny údaje, které jste zadali, a spustit proces instalace znovu od začátku?", "config-restart": "Ano, restartovat", "config-welcome": "=== Kontrola prostředí ===\nNyní se provedou základní kontroly, aby se zjistilo, zda je toto prostředí použitelné k instalaci MediaWiki.\nPokud budete potřebovat k dokončení instalace pomoc, nezapomeňte sdělit výsledky těchto testů.", - "config-copyright": "=== Licence a podmínky ===\n$1\n\nTento program je svobodný software; můžete jej šířit nebo modifikovat podle podmínek GNU General Public License, vydávané Free Software Foundation; buď verze 2 této licence anebo (podle vašeho uvážení) kterékoli pozdější verze.\n\nTento program je distribuován v naději, že bude užitečný, avšak '''bez jakékoli záruky'''; neposkytují se ani odvozené záruky '''prodejnosti''' anebo '''vhodnosti pro určitý účel'''.\nPodrobnosti se dočtete v textu GNU General Public License.\n\nKopii GNU General Public License jste měli obdržet spolu s tímto programem; pokud ne, napište na Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA nebo [https://www.gnu.org/copyleft/gpl.html si ji přečtěte online].", - "config-sidebar": "* [https://www.mediawiki.org Oficiální web MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Uživatelská příručka]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Administrátorská příručka]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]\n----\n* Čti mě\n* Poznámky k vydání\n* Licence\n* Upgrade", + "config-welcome-section-copyright": "=== Licence a podmínky ===\n$1\n\nTento program je svobodný software; můžete jej šířit nebo modifikovat podle podmínek GNU General Public License, vydávané Free Software Foundation; buď verze 2 této licence anebo (podle vašeho uvážení) kterékoli pozdější verze.\n\nTento program je distribuován v naději, že bude užitečný, avšak '''bez jakékoli záruky'''; neposkytují se ani odvozené záruky '''prodejnosti''' anebo '''vhodnosti pro určitý účel'''.\nPodrobnosti se dočtete v textu GNU General Public License.\n\n[$2 Kopii GNU General Public License] jste měli obdržet spolu s tímto programem; pokud ne, napište na Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA nebo [https://www.gnu.org/copyleft/gpl.html si ji přečtěte online].", + "config-sidebar": "* [https://www.mediawiki.org Oficiální web MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Uživatelská příručka]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Administrátorská příručka]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]", + "config-sidebar-readme": "Čti mě", + "config-sidebar-relnotes": "Poznámky k vydání", + "config-sidebar-license": "Licence", + "config-sidebar-upgrade": "Upgrade", "config-env-good": "Prostředí bylo zkontrolováno.\nMůžete nainstalovat MediaWiki.", "config-env-bad": "Prostředí bylo zkontrolováno.\nMediaWiki nelze nainstalovat.", "config-env-php": "Je nainstalováno PHP $1.", "config-env-hhvm": "Je nainstalováno HHVM $1.", - "config-unicode-using-intl": "Pro normalizaci Unicode se používá [https://pecl.php.net/intl PECL rozšíření intl].", - "config-unicode-pure-php-warning": "Upozornění: Není dostupné [https://pecl.php.net/intl PECL rozšíření intl] pro normalizaci Unicode, bude se využívat pomalá implementace v čistém PHP.\nPokud provozujete wiki s velkou návštěvností, měli byste si přečíst něco o [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalizaci Unicode].", + "config-unicode-using-intl": "Pro normalizaci Unicode se používá [https://php.net/manual/en/book.intl.php rozšíření PHP intl].", + "config-unicode-pure-php-warning": "Upozornění: Není dostupné [https://php.net/manual/en/book.intl.php rozšíření PHP intl] pro normalizaci Unicode, bude se využívat pomalá implementace v čistém PHP.\nPokud provozujete wiki s velkou návštěvností, měli byste si přečíst o [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalizaci Unicode].", "config-unicode-update-warning": "Upozornění: Nainstalovaná verze vrstvy pro normalizaci Unicode používá starší verzi knihovny [http://site.icu-project.org/ projektu ICU].\nPokud vám aspoň trochu záleží na používání Unicode, měli byste [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations ji aktualizovat].", "config-no-db": "Nepodařilo se nalézt vhodný databázový ovladač! Musíte nainstalovat databázový ovladač pro PHP.\n{{PLURAL:$2|Je podporován následující typ databáze|Jsou podporovány následující typy databází}}: $1.\n\nPokud jste si PHP přeložili sami, překonfigurujte ho se zapnutým databázovým klientem, například pomocí ./configure --with-mysqli.\nPokud jste PHP nainstalovali z balíčku Debian či Ubuntu, potřebujete nainstalovat také modul php-mysql.", "config-outdated-sqlite": "Upozornění: Máte SQLite $2, které je starší než minimálně vyžadovaná verze $1. SQLite nebude dostupné.", @@ -138,7 +143,7 @@ "config-missing-db-name": "Musíte zadat hodnotu pro „{{int:config-db-name}}“.", "config-missing-db-host": "Musíte zadat hodnotu pro „{{int:config-db-host}}“.", "config-missing-db-server-oracle": "Musíte zadat hodnotu pro „{{int:config-db-host-oracle}}“.", - "config-invalid-db-server-oracle": "Chybné databázové TNS „$1“.\nPoužívejte buď „TNS Name“ nebo „Easy Connect“ (viz [http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Oracle Naming Methods]).", + "config-invalid-db-server-oracle": "Chybné databázové TNS „$1“.\nPoužívejte buď „TNS Name“ nebo „Easy Connect“ (vizte [http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Oracle Naming Methods]).", "config-invalid-db-name": "Chybné jméno databáze „$1“.\nPoužívejte pouze ASCII písmena (a-z, A-Z), čísla (0-9), podtržítko (_) a spojovník (-).", "config-invalid-db-prefix": "Chybný databázový prefix „$1“.\nPoužívejte pouze ASCII písmena (a-z, A-Z), čísla (0-9), podtržítko (_) a spojovník (-).", "config-connection-error": "$1.\n\nZkontrolujte server, uživatelské jméno a heslo a zkuste to znovu. Pokud jako adresu databázového serveru používáte „localhost“, zkuste použít „127.0.0.1“ (a naopak).", @@ -170,9 +175,6 @@ "config-db-web-no-create-privs": "Účet uvedený pro instalaci nemá oprávnění dostatečná pro založení nového účtu.\nÚčet, který zde uvedete, již musí existovat.", "config-mysql-engine": "Typ úložiště:", "config-mysql-innodb": "InnoDB (doporučeno)", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "Upozornění: Jako typ úložiště pro MySQL jste zvolili MyISAM, které není pro použití v MediaWiki doporučeno, neboť:\n* stěží podporuje současný přístup kvůli zamykání tabulek,\n* je náchylnější na poškození dat než jiná úložiště,\n* kód MediaWiki nepodporuje MyISAM vždy tak dobře, jak by měl.\n\nPokud vaše instalace MySQL podporuje InnoDB, důrazně doporučujeme použít spíše to.\nPokud vaše instalace MySQL InnoDB nepodporuje, možná je čas na aktualizaci.", - "config-mysql-only-myisam-dep": "Upozornění: Jediným dostupným úložištěm dat pro MySQL je MyISAM, který se k užití s MediaWiki nedoporučuje, neboť:\n* téměř nepodporuje paralelní přístup kvůli zamykání tabulek,\n* oproti jiným formátům je náchylnější k poškození,\n* MediaWiki nepodporuje MyISAM tak dobře, jak by bylo třeba.\n\nVaše instalace MySQL nepodporuje InnoDB, možná je na čase upgradovat.", "config-mysql-engine-help": "'''InnoDB''' je téměř vždy nejlepší volba, neboť má dobrou podporu současného přístupu.\n\n'''MyISAM''' může být rychlejší u instalací pro jednoho uživatele nebo jen pro čtení.\nDatabáze MyISAM bývají poškozeny častěji než databáze InnoDB.", "config-mssql-auth": "Typ autentizace:", "config-mssql-install-auth": "Zvolte si typ autentizace, který se bude používat pro připojení k databázi v průběhu instalace.\nPokud zvolíte možnost „{{int:config-mssql-windowsauth}}“, použijí se přihlašovací údaje uživatele, pod kterým běží webový server.", @@ -217,7 +219,7 @@ "config-profile-no-anon": "Vyžadována registrace uživatelů", "config-profile-fishbowl": "Editace jen pro vybrané", "config-profile-private": "Soukromá wiki", - "config-profile-help": "Wiki fungují nejlépe, když je necháte editovat co největším možným počtem lidí.\nV MediaWiki můžete snadno kontrolovat poslední změny a vracet zpět libovolnou škodu způsobenou hloupými nebo zlými uživateli.\n\nMnoho lidí však zjistilo, že je MediaWiki užitečné v širokém spektru rolí a někdy není snadné všechny přesvědčit o výhodách wikizvyklostí.\nTakže si můžete vybrat.\n\nModel '''{{int:config-profile-wiki}}''' dovoluje editovat všem, aniž by se museli přihlašovat.\nNa wiki, kde je '''{{int:config-profile-no-anon}}''', se lépe řídí zodpovědnost, ale může to odradit náhodné přispěvatele.\n\nProfil '''{{int:config-profile-fishbowl}}''' umožňuje schváleným uživatelům editovat, ale veřejnost si může stránky prohlížet včetně jejich historie.\n'''{{int:config-profile-private}}''' dovoluje stránky prohlížet jen schváleným uživatelům, kteří je i mohou editovat.\n\nPo instalaci je možná komplexní konfigurace uživatelských práv; viz [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights odpovídající stránku příručky].", + "config-profile-help": "Wiki fungují nejlépe, když je necháte editovat co největším možným počtem lidí.\nV MediaWiki můžete snadno kontrolovat poslední změny a vracet zpět libovolnou škodu způsobenou hloupými nebo zlými uživateli.\n\nMnoho lidí však zjistilo, že je MediaWiki užitečné v širokém spektru rolí a někdy není snadné všechny přesvědčit o výhodách wikizvyklostí.\nTakže si můžete vybrat.\n\nModel '''{{int:config-profile-wiki}}''' dovoluje editovat všem, aniž by se museli přihlašovat.\nNa wiki, kde je '''{{int:config-profile-no-anon}}''', se lépe řídí zodpovědnost, ale může to odradit náhodné přispěvatele.\n\nProfil '''{{int:config-profile-fishbowl}}''' umožňuje schváleným uživatelům editovat, ale veřejnost si může stránky prohlížet včetně jejich historie.\n'''{{int:config-profile-private}}''' dovoluje stránky prohlížet jen schváleným uživatelům, kteří je i mohou editovat.\n\nPo instalaci je možná komplexní konfigurace uživatelských práv; vizte [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights odpovídající stránku příručky].", "config-license": "Autorská práva a licence:", "config-license-none": "Bez patičky s licencí", "config-license-cc-by-sa": "Creative Commons Uveďte autora-Zachovejte licenci", diff --git a/includes/installer/i18n/csb.json b/includes/installer/i18n/csb.json index f7df024a81..38bb36b9b7 100644 --- a/includes/installer/i18n/csb.json +++ b/includes/installer/i18n/csb.json @@ -39,7 +39,6 @@ "config-wincache": "[https://www.iis.net/downloads/microsoft/wincache-extension WinCache] je wjinstalowóny", "config-diff3-bad": "Felënk GNU diff3.", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", "config-site-name": "Miono wiki:", "config-site-name-blank": "Wpiszë miono starnów.", "config-ns-other-default": "MòjôWiki", diff --git a/includes/installer/i18n/da.json b/includes/installer/i18n/da.json index 946955e3b2..8924964251 100644 --- a/includes/installer/i18n/da.json +++ b/includes/installer/i18n/da.json @@ -44,6 +44,7 @@ "config-page-existingwiki": "Eksisterende wiki", "config-help-restart": "Vil du rydde alle gemte data, du har indtastet og genstarte installationen?", "config-restart": "Ja, genstart den", + "config-sidebar-upgrade": "Opgraderer", "config-env-php": "PHP $1 er installeret.", "config-env-hhvm": "HHVM $1 er installeret.", "config-apc": "[https://www.php.net/apc APC] er installeret", @@ -68,7 +69,6 @@ "config-sqlite-cant-create-db": "Kunne ikke oprette databasefilen $1.", "config-db-web-create": "Opret kontoen hvis den ikke allerede findes", "config-mysql-innodb": "InnoDB (anbefalet)", - "config-mysql-myisam": "MyISAM", "config-mssql-windowsauth": "Windows-godkendelse", "config-site-name": "Navn på wiki:", "config-site-name-blank": "Indtast et hjemmesidenavn.", diff --git a/includes/installer/i18n/de-ch.json b/includes/installer/i18n/de-ch.json index d307c8daa4..f10530eb92 100644 --- a/includes/installer/i18n/de-ch.json +++ b/includes/installer/i18n/de-ch.json @@ -5,7 +5,7 @@ "Das Schäfchen" ] }, - "config-copyright": "=== Lizenz und Nutzungsbedingungen ===\n\n$1\n\nDieses Programm ist freie Software, d. h. es kann, gemäss den Bedingungen der von der Free Software Foundation veröffentlichten ''GNU General Public License'', weiterverteilt und/oder modifiziert werden. Dabei kann die Version 2, oder nach eigenem Ermessen, jede neuere Version der Lizenz verwendet werden.\n\nDieses Programm wird in der Hoffnung verteilt, dass es nützlich sein wird, allerdings '''ohne jegliche Garantie''' und sogar ohne die implizierte Garantie einer '''Marktgängigkeit''' oder '''Eignung für einen bestimmten Zweck'''. Hierzu sind weitere Hinweise in der ''GNU General Public License'' enthalten.\n\nEine Kopie der GNU General Public License sollte zusammen mit diesem Programm verteilt worden sein. Sofern dies nicht der Fall war, kann eine Kopie bei der Free Software Foundation Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, schriftlich angefordert oder auf deren Website [https://www.gnu.org/copyleft/gpl.html online gelesen] werden.", + "config-welcome-section-copyright": "=== Lizenz und Nutzungsbedingungen ===\n\n$1\n\nDieses Programm ist freie Software, d. h. es kann, gemäss den Bedingungen der von der Free Software Foundation veröffentlichten ''GNU General Public License'', weiterverteilt und/oder modifiziert werden. Dabei kann die Version 2, oder nach eigenem Ermessen, jede neuere Version der Lizenz verwendet werden.\n\nDieses Programm wird in der Hoffnung verteilt, dass es nützlich sein wird, allerdings '''ohne jegliche Garantie''' und sogar ohne die implizierte Garantie einer '''Marktgängigkeit''' oder '''Eignung für einen bestimmten Zweck'''. Hierzu sind weitere Hinweise in der ''GNU General Public License'' enthalten.\n\nEine [$2 Kopie der GNU General Public License] sollte zusammen mit diesem Programm verteilt worden sein. Sofern dies nicht der Fall war, kann eine Kopie bei der Free Software Foundation Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, schriftlich angefordert oder auf deren Website [https://www.gnu.org/copyleft/gpl.html online gelesen] werden.", "config-unicode-pure-php-warning": "'''Warnung:''' Die [https://pecl.php.net/intl PECL-Erweiterung intl] ist für die Unicode-Normalisierung nicht verfügbar, so dass stattdessen die langsame pure-PHP-Implementierung genutzt wird.\nSofern eine Website mit grosser Benutzeranzahl betrieben wird, sollten weitere Informationen auf der Webseite [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode-Normalisierung (en)] gelesen werden.", "config-uploads-not-safe": "'''Warnung:''' Das Standardverzeichnis für hochgeladene Dateien $1 ist für die willkürliche Ausführung von Skripten anfällig.\nObwohl MediaWiki die hochgeladenen Dateien auf Sicherheitsrisiken überprüft, wird dennoch dringend empfohlen, diese [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security Sicherheitslücke] zu schliessen, bevor das Hochladen von Dateien aktiviert wird.", "config-license-help": "Viele öffentliche Wikis publizieren alle Beiträge unter einer [https://freedomdefined.org/Definition/De freien Lizenz.]\nDies trägt dazu bei, ein Gefühl von Gemeinschaft zu schaffen, und ermutigt zu längerfristiger Mitarbeit.\nHingegen ist im Allgemeinen eine freie Lizenz auf geschlossenen Wikis nicht notwendig.\n\nSofern man Texte aus der Wikipedia verwenden möchte und umgekehrt, sollte die ''Creative-Commons''-Lizenz „Namensnennung – Weitergabe unter gleichen Bedingungen“ gewählt werden.\n\nDie Wikipedia nutzte vormals die GNU-Lizenz für freie Dokumentation (GFDL).\nDie GFDL ist eine gültige Lizenz, die allerdings schwer zu verstehen ist.\nEs ist zudem schwierig, gemäss dieser Lizenz lizenzierte Inhalte wiederzuverwenden." diff --git a/includes/installer/i18n/de.json b/includes/installer/i18n/de.json index 5fcc4c92a6..bdf1c6f639 100644 --- a/includes/installer/i18n/de.json +++ b/includes/installer/i18n/de.json @@ -56,7 +56,7 @@ "config-help-restart": "Sollen alle bereits eingegebenen Daten gelöscht und der Installationsvorgang erneut gestartet werden?", "config-restart": "Ja, erneut starten", "config-welcome": "=== Prüfung der Installationsumgebung ===\nDie Basisprüfungen werden jetzt durchgeführt, um festzustellen, ob die Installationsumgebung für MediaWiki geeignet ist.\nNotiere diese Informationen und gib sie an, sofern du Hilfe beim Installieren benötigst.", - "config-copyright": "=== Lizenz und Nutzungsbedingungen ===\n\n$1\n\nDieses Programm ist freie Software, d. h. es kann, gemäß den Bedingungen der von der Free Software Foundation veröffentlichten ''GNU General Public License'', weiterverteilt und/oder modifiziert werden. Dabei kann die Version 2, oder nach eigenem Ermessen, jede neuere Version der Lizenz verwendet werden.\n\nDieses Programm wird in der Hoffnung verteilt, dass es nützlich sein wird, allerdings '''ohne jegliche Garantie''' und sogar ohne die implizierte Garantie einer '''Marktgängigkeit''' oder '''Eignung für einen bestimmten Zweck'''. Hierzu sind weitere Hinweise in der ''GNU General Public License'' enthalten.\n\nEine Kopie der GNU General Public License sollte zusammen mit diesem Programm verteilt worden sein. Sofern dies nicht der Fall war, kann eine Kopie bei der Free Software Foundation Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, schriftlich angefordert oder auf deren Website [https://www.gnu.org/copyleft/gpl.html online gelesen] werden.", + "config-welcome-section-copyright": "=== Lizenz und Nutzungsbedingungen ===\n\n$1\n\nDieses Programm ist freie Software, d. h. es kann, gemäß den Bedingungen der von der Free Software Foundation veröffentlichten ''GNU General Public License'', weiterverteilt und/oder modifiziert werden. Dabei kann die Version 2, oder nach eigenem Ermessen, jede neuere Version der Lizenz verwendet werden.\n\nDieses Programm wird in der Hoffnung verteilt, dass es nützlich sein wird, allerdings '''ohne jegliche Garantie''' und sogar ohne die implizierte Garantie einer '''Marktgängigkeit''' oder '''Eignung für einen bestimmten Zweck'''. Hierzu sind weitere Hinweise in der ''GNU General Public License'' enthalten.\n\nEine [$2 Kopie der GNU General Public License] sollte zusammen mit diesem Programm verteilt worden sein. Sofern dies nicht der Fall war, kann eine Kopie bei der Free Software Foundation Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, schriftlich angefordert oder auf deren Website [https://www.gnu.org/copyleft/gpl.html online gelesen] werden.", "config-sidebar": "* [https://www.mediawiki.org/wiki/MediaWiki/de Website von MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents/de Benutzer­anleitung]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents/de Administratoren­anleitung]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/de Häufig gestellte Fragen]\n----\n* Lies mich\n* Versions­informationen\n* Lizenz­bestimmungen\n* Aktualisierung", "config-env-good": "Die Installationsumgebung wurde geprüft.\nMediaWiki kann installiert werden.", "config-env-bad": "Die Installationsumgebung wurde geprüft.\nMediaWiki kann nicht installiert werden.", @@ -174,9 +174,6 @@ "config-db-web-no-create-privs": "Das angegebene und für den Installationsvorgang vorgesehene Datenbankkonto verfügt nicht über ausreichend Berechtigungen, um ein weiteres Datenbankkonto zu erstellen.\nDas hier angegebene Datenbankkonto muss daher bereits vorhanden sein.", "config-mysql-engine": "Datenbanksystem:", "config-mysql-innodb": "InnoDB (empfohlen)", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "Warnung: Es wurde MyISAM als Speichersubsystem für das Datenbanksystem MySQL ausgewählt. Aus folgenden Gründen wird es nicht für den Einsatz mit MediaWiki empfohlen:\n* Es unterstützt aufgrund von Tabellensperrungen kaum die nebenläufige Ausführung von Aktionen.\n* Es ist anfälliger für Datenprobleme.\n* Es wird von MediaWiki nicht immer adäquat unterstützt.\n\nSofern die vorhandene MySQL-Installation das Speichersubsystem InnoDB unterstützt, wird deren Verwendung eindringlich empfohlen.\nSofern sie es nicht unterstützt, sollte nunmehr eine entsprechende Aktualisierung in Erwägung gezogen werden.", - "config-mysql-only-myisam-dep": "Warnung: MyISAM ist das einzige verfügbare Speichersubsystem für das Datenbanksystem MySQL auf diesem Server. Es wird nicht für die Verwendung mit MediaWiki empfohlen, da es\n* aufgrund von Tabellensperrungen kaum die nebenläufige Ausführung von Aktionen unterstützt,\n* anfälliger für Datenprobleme ist und\n* von MediaWiki nicht immer adäquat unterstützt wird.\n\nDeine MySQL-Installation unterstützt nicht das Speichersubsystem InnoDB. Eine Aktualisierung wird nunmehr empfohlen.", "config-mysql-engine-help": "InnoDB als Speichersubsystem für das Datenbanksystem MySQL ist fast immer die bessere Wahl, da es gleichzeitige Zugriffe gut unterstützt.\n\nMyISAM als Speichersubsystem für das Datenbanksystem MySQL ist hingegen in Einzelnutzerumgebungen oder bei schreibgeschützten Wikis schneller.\nDatenbanken, die MyISAM verwenden, sind indes tendenziell fehleranfälliger als solche, die InnoDB verwenden.", "config-mssql-auth": "Authentifikationstyp:", "config-mssql-install-auth": "Wähle den Authentifikationstyp aus, der zur Verbindung mit der Datenbank während des Installationsprozesses verwendet wird.\nFalls du „{{int:config-mssql-windowsauth}}“ auswählst, werden die Anmeldeinformationen eines beliebigen Benutzers verwendet, der den Webserver ausführt.", @@ -224,10 +221,10 @@ "config-profile-help": "Wikis sind am nützlichsten, wenn so viele Menschen als möglich Bearbeitungen vornehmen können.\nMit MediaWiki ist es einfach die letzten Änderungen nachzuvollziehen und unbrauchbare Bearbeitungen, beispielsweise von unbedarften oder böswilligen Benutzern, rückgängig zu machen.\n\nAllerdings finden etliche Menschen Wikis auch mit anderen Bearbeitungskonzepten sinnvoll. Manchmal ist es zudem nicht einfach alle Beteiligten von den Vorteilen des „Wiki-Prinzips” zu überzeugen. Darum ist diese Auswahl möglich.\n\nDas Modell „'''{{int:config-profile-wiki}}'''“ ermöglicht es jedermann, sogar ohne über ein Benutzerkonto zu verfügen, Bearbeitungen vorzunehmen.\nEin Wiki bei dem die '''{{int:config-profile-no-anon}}''' ist, fordert von den Benutzern eine höhere Verantwortung für ihre Bearbeitungen ein, könnte allerdings Personen abschrecken, die nur gelegentlich Bearbeitungen vornehmen wollen. Ein Wiki für '''{{int:config-profile-fishbowl}}''' gestattet es nur bestimmten Benutzern, Bearbeitungen vorzunehmen. Allerdings kann dabei die Allgemeinheit die Seiten immer noch betrachten und Änderungen nachvollziehen. Ein '''{{int:config-profile-private}}''' gestattet es nur ausgewählten Benutzern, Seiten zu betrachten sowie zu bearbeiten.\n\nKomplexere Konzepte zur Zugriffssteuerung können erst nach abgeschlossenem Installationsvorgang eingerichtet werden. Hierzu gibt es weitere Informationen auf der Website mit der [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights entsprechenden Anleitung].", "config-license": "Lizenz:", "config-license-none": "Keine Lizenzangabe in der Fußzeile", - "config-license-cc-by-sa": "''Creative Commons'' „Namensnennung – Weitergabe unter gleichen Bedingungen“", - "config-license-cc-by": "''Creative Commons'' „Namensnennung“", - "config-license-cc-by-nc-sa": "''Creative Commons'' „Namensnennung – nicht kommerziell – Weitergabe unter gleichen Bedingungen“", - "config-license-cc-0": "''Creative Commons'' „Zero“ (Gemeinfreiheit)", + "config-license-cc-by-sa": "Creative Commons „Namensnennung – Weitergabe unter gleichen Bedingungen“", + "config-license-cc-by": "Creative Commons „Namensnennung“", + "config-license-cc-by-nc-sa": "Creative Commons „Namensnennung – nicht kommerziell – Weitergabe unter gleichen Bedingungen“", + "config-license-cc-0": "Creative Commons „Zero“ (Gemeinfreiheit)", "config-license-gfdl": "GNU-Lizenz für freie Dokumentation 1.3 oder höher", "config-license-pd": "Gemeinfreiheit", "config-license-cc-choose": "Eine andere Creative-Commons-Lizenz auswählen", diff --git a/includes/installer/i18n/diq.json b/includes/installer/i18n/diq.json index 21c72494ac..d5967849ae 100644 --- a/includes/installer/i18n/diq.json +++ b/includes/installer/i18n/diq.json @@ -58,7 +58,6 @@ "config-missing-db-server-oracle": "\"{{int:config-db-host-oracle}}\" rë jew erc gerek keno", "config-mysql-engine": "Motorë depok kerdışi", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", "config-mssql-sqlauth": "SQL Server araştnayış", "config-mssql-windowsauth": "Windows kamiye araştnayış", "config-site-name": "Namey wiki:", diff --git a/includes/installer/i18n/dty.json b/includes/installer/i18n/dty.json index 896a453eda..c5b07faec4 100644 --- a/includes/installer/i18n/dty.json +++ b/includes/installer/i18n/dty.json @@ -41,7 +41,7 @@ "config-help-restart": "Do you want to clear all saved data that you have entered and restart the installation process?", "config-restart": "हुन्छ, पुनः सुचारू गद्दे", "config-welcome": "=== Environmental checks ===\nBasic checks will now be performed to see if this environment is suitable for MediaWiki installation.\nRemember to include this information if you seek support on how to complete the installation.", - "config-copyright": "=== Copyright and Terms ===\n\n$1\n\nThis 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.\n\nThis 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.\nSee the GNU General Public License for more details.\n\nYou 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, or [https://www.gnu.org/copyleft/gpl.html read it online].", + "config-welcome-section-copyright": "=== Copyright and Terms ===\n\n$1\n\nThis 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.\n\nThis 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.\nSee the GNU General Public License for more details.\n\nYou should have received [$2 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, or [https://www.gnu.org/copyleft/gpl.html read it online].", "config-sidebar": "* [https://www.mediawiki.org MediaWiki home]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents User's Guide]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Administrator's Guide]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]\n----\n* Read me\n* Release notes\n* Copying\n* Upgrading", "config-env-good": "The environment has been checked.\nYou can install MediaWiki.", "config-env-bad": "The environment has been checked.\nYou cannot install MediaWiki.", diff --git a/includes/installer/i18n/el.json b/includes/installer/i18n/el.json index b7a4e06ed4..97b4fd35a8 100644 --- a/includes/installer/i18n/el.json +++ b/includes/installer/i18n/el.json @@ -52,7 +52,7 @@ "config-help-restart": "Θέλετε να καταργήσετε όλα τα αποθηκευμένα δεδομένα που έχετε εισαγάγει και να επανεκκινήσετε τη διαδικασία εγκατάστασης;", "config-restart": "Ναι, επανεκκίνηση", "config-welcome": "=== Έλεγχοι του περιβάλλοντος ===\nΤώρα θα γίνουν βασικοί έλεγχοι για να δούμε αν αυτό το περιβάλλον είναι κατάλληλο για την εγκατάσταση του MediaWiki.\nΘυμηθείτε να συμπεριλάβετε αυτές τις πληροφορίες εάν αναζητήσετε υποστήριξη για να ολοκληρώσετε την εγκατάσταση.", - "config-copyright": "=== Πνευματικά δικαιώματα και όροι ===\n\n$1\n\nΑυτό το πρόγραμμα είναι ελεύθερο λογισμικό· μπορείτε να το αναδιανείμετε ή/και να το τροποποιήσετε υπό τους όρους της Γενικής Άδειας Δημόσιας Χρήσης GNU, όπως αυτή δημοσιεύεται από το Ίδρυμα Ελεύθερου Λογισμικού· είτε της έκδοσης 2 της Άδειας, είτε (κατ' επιλογήν σας) οποιασδήποτε μεταγενέστερης έκδοσης.\n\nΑυτό το πρόγραμμα διανέμεται με την ελπίδα ότι θα είναι χρήσιμο, αλλά χωρίς καμία εγγύηση· χωρίς καν τη σιωπηρή εγγύηση της εμπορευσιμότητας ή καταλληλότητας για συγκεκριμένο σκοπό.\nΔείτε τη Γενική Άδεια Δημόσιας Χρήσης GNU για περισσότερες λεπτομέρειες.\n\nΘα πρέπει να έχετε παραλάβει ένα αντίγραφο της Γενικής Άδειας Δημόσιας Χρήσης GNU μαζί με αυτό το πρόγραμμα· αν όχι, στείλτε ένα γράμμα στο Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, ή [https://www.gnu.org/copyleft/gpl.html διαβάστε το διαδικτυακά].", + "config-welcome-section-copyright": "=== Πνευματικά δικαιώματα και όροι ===\n\n$1\n\nΑυτό το πρόγραμμα είναι ελεύθερο λογισμικό· μπορείτε να το αναδιανείμετε ή/και να το τροποποιήσετε υπό τους όρους της Γενικής Άδειας Δημόσιας Χρήσης GNU, όπως αυτή δημοσιεύεται από το Ίδρυμα Ελεύθερου Λογισμικού· είτε της έκδοσης 2 της Άδειας, είτε (κατ' επιλογήν σας) οποιασδήποτε μεταγενέστερης έκδοσης.\n\nΑυτό το πρόγραμμα διανέμεται με την ελπίδα ότι θα είναι χρήσιμο, αλλά χωρίς καμία εγγύηση· χωρίς καν τη σιωπηρή εγγύηση της εμπορευσιμότητας ή καταλληλότητας για συγκεκριμένο σκοπό.\nΔείτε τη Γενική Άδεια Δημόσιας Χρήσης GNU για περισσότερες λεπτομέρειες.\n\nΘα πρέπει να έχετε παραλάβει [$2 ένα αντίγραφο της Γενικής Άδειας Δημόσιας Χρήσης GNU] μαζί με αυτό το πρόγραμμα· αν όχι, στείλτε ένα γράμμα στο Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, ή [https://www.gnu.org/copyleft/gpl.html διαβάστε το διαδικτυακά].", "config-sidebar": "* [https://www.mediawiki.org Αρχική MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Οδηγός Χρήστη]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Οδηγός Διαχειριστή]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Συχνές ερωτήσεις]\n----\n* Διαβάστε με\n* Σημειώσεις έκδοσης\n* Αντιγραφή\n* Αναβάθμιση", "config-env-good": "Το περιβάλλον έχει ελεγχθεί.\nΜπορείτε να εγκαταστήσετε το MediaWiki.", "config-env-bad": "Το περιβάλλον έχει ελεγχθεί.\nΔεν μπορείτε να εγκαταστήσετε το MediaWiki.", @@ -142,7 +142,6 @@ "config-db-web-create": "Να δημιουργηθεί ο λογαριασμός αν δεν υπάρχει ήδη", "config-mysql-engine": "Μηχανή αποθήκευσης:", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", "config-mysql-engine-help": "Το InnoDB είναι σχεδόν πάντα η καλύτερη επιλογή, αφού έχει καλή υποστήριξη ταυτόχρονης λειτουργίας.\n\nΤο MyISAM μπορεί να είναι ταχύτερο σε εγκαταστάσεις του ενός χρήστη ή μόνο ανάγνωσης. \nΟι βάσεις δεδομένων MyISAM τείνουν να φθείρονται συχνότερα από τις βάσεις δεδομένων InnoDB.", "config-mssql-auth": "Τύπος ελέγχου ταυτότητας:", "config-mssql-sqlauth": "Έλεγχος ταυτότητας του SQL Server", diff --git a/includes/installer/i18n/en-gb.json b/includes/installer/i18n/en-gb.json index 7476c890ba..7f81a8895d 100644 --- a/includes/installer/i18n/en-gb.json +++ b/includes/installer/i18n/en-gb.json @@ -8,7 +8,7 @@ "config-desc": "The installer for MediaWiki", "config-title": "MediaWiki $1 installation", "config-information": "Information", - "config-copyright": "=== Copyright and Terms ===\n\n$1\n\nThis program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public Licence as published by the Free Software Foundation; either version 2 of the Licence, or (at your option) any later version.\n\nThis 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'''.\nSee the GNU General Public Licence for more details.\n\nYou should have received a copy of the GNU General Public Licence along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. or [https://www.gnu.org/copyleft/gpl.html read it online].", + "config-welcome-section-copyright": "=== Copyright and Terms ===\n\n$1\n\nThis program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public Licence as published by the Free Software Foundation; either version 2 of the Licence, or (at your option) any later version.\n\nThis 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'''.\nSee the GNU General Public Licence for more details.\n\nYou should have received [$2 a copy of the GNU General Public Licence] along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. or [https://www.gnu.org/copyleft/gpl.html read it online].", "config-unicode-using-intl": "Using the [https://pecl.php.net/intl intl PECL extension] for Unicode normalisation.", "config-unicode-pure-php-warning": "'''Warning:''' The [https://pecl.php.net/intl intl PECL extension] is not available to handle Unicode normalisation, falling back to slow pure-PHP implementation.\nIf you run a high-traffic site, you should read a little on [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode normalisation].", "config-unicode-update-warning": "'''Warning:''' The installed version of the Unicode normalisation wrapper uses an older version of [http://site.icu-project.org/ the ICU project's] library.\nYou should [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations upgrade] if you are at all concerned about using Unicode.", diff --git a/includes/installer/i18n/en.json b/includes/installer/i18n/en.json index 3705a8d8ae..a9da56dc43 100644 --- a/includes/installer/i18n/en.json +++ b/includes/installer/i18n/en.json @@ -39,14 +39,18 @@ "config-help-restart": "Do you want to clear all saved data that you have entered and restart the installation process?", "config-restart": "Yes, restart it", "config-welcome": "=== Environmental checks ===\nBasic checks will now be performed to see if this environment is suitable for MediaWiki installation.\nRemember to include this information if you seek support on how to complete the installation.", - "config-copyright": "=== Copyright and Terms ===\n\n$1\n\nThis 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.\n\nThis 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.\nSee the GNU General Public License for more details.\n\nYou 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, or [https://www.gnu.org/copyleft/gpl.html read it online].", - "config-sidebar": "* [https://www.mediawiki.org MediaWiki home]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents User's Guide]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Administrator's Guide]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]\n----\n* Read me\n* Release notes\n* Copying\n* Upgrading", + "config-welcome-section-copyright": "=== Copyright and Terms ===\n\n$1\n\nThis 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.\n\nThis 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.\nSee the GNU General Public License for more details.\n\nYou should have received [$2 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, or [https://www.gnu.org/copyleft/gpl.html read it online].", + "config-sidebar": "* [https://www.mediawiki.org MediaWiki home]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents User's Guide]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Administrator's Guide]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]", + "config-sidebar-readme": "Read me", + "config-sidebar-relnotes": "Release notes", + "config-sidebar-license": "Copying", + "config-sidebar-upgrade": "Upgrading", "config-env-good": "The environment has been checked.\nYou can install MediaWiki.", "config-env-bad": "The environment has been checked.\nYou cannot install MediaWiki.", "config-env-php": "PHP $1 is installed.", "config-env-hhvm": "HHVM $1 is installed.", - "config-unicode-using-intl": "Using the [https://pecl.php.net/intl intl PECL extension] for Unicode normalization.", - "config-unicode-pure-php-warning": "Warning: The [https://pecl.php.net/intl intl PECL extension] is not available to handle Unicode normalization, falling back to slow pure-PHP implementation.\nIf you run a high-traffic site, you should read a little on [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode normalization].", + "config-unicode-using-intl": "Using the [https://php.net/manual/en/book.intl.php PHP intl extension] for Unicode normalization.", + "config-unicode-pure-php-warning": "Warning: The [https://php.net/manual/en/book.intl.php PHP intl extension] is not available to handle Unicode normalization, falling back to slow pure-PHP implementation.\nIf you run a high-traffic site, you should read on [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode normalization].", "config-unicode-update-warning": "Warning: The installed version of the Unicode normalization wrapper uses an older version of [http://site.icu-project.org/ the ICU project's] library.\nYou should [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations upgrade] if you are at all concerned about using Unicode.", "config-no-db": "Could not find a suitable database driver! You need to install a database driver for PHP.\nThe following database {{PLURAL:$2|type is|types are}} supported: $1.\n\nIf you compiled PHP yourself, reconfigure it with a database client enabled, for example, using ./configure --with-mysqli.\nIf you installed PHP from a Debian or Ubuntu package, then you also need to install, for example, the php-mysql package.", "config-outdated-sqlite": "Warning: you have SQLite $2, which is lower than minimum required version $1. SQLite will be unavailable.", @@ -158,9 +162,6 @@ "config-db-web-no-create-privs": "The account you specified for installation does not have enough privileges to create an account.\nThe account you specify here must already exist.", "config-mysql-engine": "Storage engine:", "config-mysql-innodb": "InnoDB (recommended)", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "Warning: You have selected MyISAM as storage engine for MySQL, which is not recommended for use with MediaWiki, because:\n* it barely supports concurrency due to table locking\n* it is more prone to corruption than other engines\n* the MediaWiki codebase does not always handle MyISAM as it should\n\nIf your MySQL installation supports InnoDB, it is highly recommended that you choose that instead.\nIf your MySQL installation does not support InnoDB, maybe it's time for an upgrade.", - "config-mysql-only-myisam-dep": "Warning: MyISAM is the only available storage engine for MySQL on this machine, and this is not recommended for use with MediaWiki, because:\n* it barely supports concurrency due to table locking\n* it is more prone to corruption than other engines\n* the MediaWiki codebase does not always handle MyISAM as it should\n\nYour MySQL installation does not support InnoDB, maybe it's time for an upgrade.", "config-mysql-engine-help": "InnoDB is almost always the best option, since it has good concurrency support.\n\nMyISAM may be faster in single-user or read-only installations.\nMyISAM databases tend to get corrupted more often than InnoDB databases.", "config-mssql-auth": "Authentication type:", "config-mssql-install-auth": "Select the authentication type that will be used to connect to the database during the installation process.\nIf you select \"{{int:config-mssql-windowsauth}}\", the credentials of whatever user the webserver is running as will be used.", diff --git a/includes/installer/i18n/eo.json b/includes/installer/i18n/eo.json index 6d52241fe9..3f83612474 100644 --- a/includes/installer/i18n/eo.json +++ b/includes/installer/i18n/eo.json @@ -75,7 +75,6 @@ "config-regenerate": "Refari dosieron LocalSettings.php →", "config-mysql-engine": "Konservada modulo:", "config-mysql-innodb": "InnoDB (rekomendata)", - "config-mysql-myisam": "MyISAM", "config-mssql-auth": "Speco de aŭtentokontrolo:", "config-mssql-sqlauth": "Aŭtentokontrolo de Microsoft SQL-Servilo", "config-mssql-windowsauth": "Aŭtentokontrolo de Windows", diff --git a/includes/installer/i18n/es.json b/includes/installer/i18n/es.json index feef3357b6..5e3d3a1034 100644 --- a/includes/installer/i18n/es.json +++ b/includes/installer/i18n/es.json @@ -39,7 +39,9 @@ "Adjen", "Dschultz", "Carlosmg.dg", - "Harvest" + "Harvest", + "Anarhistička Maca", + "Johny Weissmuller Jr" ] }, "config-desc": "El instalador de MediaWiki", @@ -79,14 +81,18 @@ "config-help-restart": "¿Deseas borrar todos los datos guardados que has escrito y reiniciar el proceso de instalación?", "config-restart": "Sí, reiniciarlo", "config-welcome": "=== Comprobación del entorno ===\nAhora se van a realizar comprobaciones básicas para ver si el entorno es adecuado para la instalación de MediaWiki.\nRecuerda suministrar los resultados de tales comprobaciones si necesitas ayuda para completar la instalación.", - "config-copyright": "=== Derechos de autor y Términos de uso ===\n\n$1\n\nEste programa es software libre; puedes redistribuirlo y/o modificarlo en los términos de la Licencia Pública General de GNU, tal como aparece publicada por la Fundación para el Software Libre, tanto la versión 2 de la Licencia, como cualquier versión posterior (según prefieras).\n\nEste programa es distribuido con la esperanza de que sea útil, pero sin ninguna garantía; inclusive, sin la garantía implícita de la posibilidad de ser comercializado o de idoneidad para cualquier finalidad específica.\nConsulta la Licencia Pública General de GNU para más detalles.\n\nEn conjunto con este programa debes haber recibido una copia de la Licencia Pública General de GNU; caso contrario, pídela por escrito a la Fundación para el Software Libre, Inc., 51 Franklin Street, Fifth Floor, Boston, ME La 02110-1301, USA o [https://www.gnu.org/copyleft/gpl.html léela en Internet].", - "config-sidebar": "* [https://www.mediawiki.org Página principal de MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Guía del usuario]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Guía del administrador]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Preguntas frecuentes]\n----\n* Léeme\n* Notas de la versión\n* Copia\n* Actualización", + "config-welcome-section-copyright": "=== Derechos de autor y Términos de uso ===\n\n$1\n\nEste programa es software libre; puedes redistribuirlo y/o modificarlo en los términos de la Licencia Pública General de GNU, tal como aparece publicada por la Fundación para el Software Libre, tanto la versión 2 de la Licencia, como cualquier versión posterior (según prefieras).\n\nEste programa es distribuido con la esperanza de que sea útil, pero sin ninguna garantía; inclusive, sin la garantía implícita de la posibilidad de ser comercializado o de idoneidad para cualquier finalidad específica.\nConsulta la Licencia Pública General de GNU para más detalles.\n\nEn conjunto con este programa debes haber recibido [$2 una copia de la Licencia Pública General de GNU]; caso contrario, pídela por escrito a la Fundación para el Software Libre, Inc., 51 Franklin Street, Fifth Floor, Boston, ME La 02110-1301, USA o [https://www.gnu.org/copyleft/gpl.html léela en Internet].", + "config-sidebar": "* [https://www.mediawiki.org Página principal de MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Guía de usuario]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Guía para administradores]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Preguntas frecuentes]", + "config-sidebar-readme": "Léeme", + "config-sidebar-relnotes": "Informe de novedades", + "config-sidebar-license": "Cómo copiar", + "config-sidebar-upgrade": "Cómo actualizar", "config-env-good": "El entorno ha sido comprobado.\nPuedes instalar MediaWiki.", "config-env-bad": "El entorno ha sido comprobado.\nNo puedes instalar MediaWiki.", "config-env-php": "PHP $1 está instalado.", "config-env-hhvm": "HHVM $1 está instalado.", - "config-unicode-using-intl": "Se utiliza la [https://pecl.php.net/intl extensión «intl» de PECL] para la normalización Unicode.", - "config-unicode-pure-php-warning": "Advertencia: la [https://pecl.php.net/intl extensión intl] no está disponible para efectuar la normalización Unicode. Se utilizará la implementación más lenta en PHP puro.\nSi tu web tiene mucho tráfico, te recomendamos leer acerca de la [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalización Unicode].", + "config-unicode-using-intl": "Se utiliza la [https://php.net/manual/en/book.intl.php PHP extensión «intl» de PECL] para la normalización Unicode.", + "config-unicode-pure-php-warning": "Advertencia: la [https://php.net/manual/en/book.intl.php PHP extensión intl] no está disponible para efectuar la normalización Unicode. Se utilizará la implementación más lenta en PHP puro.\nSi tu web tiene mucho tráfico, te recomendamos leer acerca [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalización Unicode].", "config-unicode-update-warning": "Atención: la versión instalada del contenedor de normalización de Unicode utiliza una versión anticuada de la biblioteca del [http://site.icu-project.org/ proyecto ICU].\nDeberías [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations modernizarla] si te interesa utilizar Unicode.", "config-no-db": "No se encontró un controlador adecuado para la base de datos. Necesitas instalar un controlador de base de datos para PHP.\n{{PLURAL:$2|Se admite el siguiente gestor de bases de datos|Se admiten los siguientes gestores de bases de datos}}: $1.\n\nSi compilaste PHP por tu cuenta, debes reconfigurarlo activando un cliente de base de datos, por ejemplo, mediante ./configure --with-mysqli.\nSi instalaste PHP desde un paquete de Debian o Ubuntu, también debes instalar, por ejemplo, el paquete php-mysql.", "config-outdated-sqlite": "Advertencia: tienes SQLite $2, que es inferior a la mínima versión requerida: $1. SQLite no estará disponible.", @@ -196,9 +202,6 @@ "config-db-web-no-create-privs": "La cuenta que has especificado para la instalación no tiene privilegios suficientes para crear una cuenta.\nLa cuenta que especifiques aquí ya debe existir.", "config-mysql-engine": "Motor de almacenamiento:", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "Atención: has seleccionado MyISAM como motor de almacenamiento de MySQL, el cual no está recomendado para usarse con MediaWiki, porque:\n* apenas admite la concurrencia debido al bloqueo de tablas\n* es más propenso a daños que otros motores\n* el código MediaWiki no siempre controla MyISAM como debería\n\nSi tu instalación de MySQL admite InnoDB, es muy recomendable que lo elijas en su lugar.\nSi tu instalación de MySQL no admite InnoDB, quizás es el momento de una modernización.", - "config-mysql-only-myisam-dep": "Advertencia: solo se ha encontrado el motor de almacenamiento MyISAM para MySQL en esta máquina, y no se recomienda su uso con MediaWiki, porque:\n* apenas admite la concurrencia debido al bloqueo de tablas\n* es más propenso a daños que otros motores\n* el código de MediaWiki no siempre controla MyISAM como debería\n\nTu instalación de MySQL no admite InnoDB; quizás es el momento de una actualización.", "config-mysql-engine-help": "InnoDB es casi siempre la mejor opción, dado que soporta bien los accesos simultáneos.\n\nMyISAM puede ser más rápido en instalaciones con usuario único o de sólo lectura.\nLas bases de datos MyISAM tienden a corromperse más a menudo que las bases de datos InnoDB.", "config-mssql-auth": "Tipo de autenticación:", "config-mssql-install-auth": "Selecciona el tipo de autenticación que se utilizará para conectarse a la base de datos durante el proceso de instalación.\nSi seleccionas \"{{int:config-mssql-windowsauth}}\", se usarán las credenciales del usuario con el que se ejecuta el servidor web.", diff --git a/includes/installer/i18n/eu.json b/includes/installer/i18n/eu.json index 61a1d2fce0..4e004f9301 100644 --- a/includes/installer/i18n/eu.json +++ b/includes/installer/i18n/eu.json @@ -46,7 +46,7 @@ "config-help-restart": "Ezabatu nahi duzu gorde duzun informazio guztia eta berrebiarazi instalazio prozesua?", "config-restart": "Bai, berriz hasi", "config-welcome": "=== Ingurumen-egiaztapenak ===\n\nOinarrizko kontrola burutzen ari da, ikusteko ia ingurumena aproposa da MediaWikia instalatzeko.\nLaguntza behar izanez gero instalazio prozesua amaitzeko ez ahaztu sartzea informazio hau .", - "config-copyright": "=== Copyright eta terminoak ===\n\n$1\n\nPrograma hau software librea da; birbana eta / edo alda dezakezu GNU Lizentzia Publiko Orokorraren baldintzapean, Free Software Foundation-ek argitaratutakoaren arabera; Lizentziaren 2. bertsioa edo (nahiago baduzu) bertsio berriago bat.\n\nPrograma hau baliagarria izango delakoan elkarbantzen da, baina bermerik gabe ; merkaturatze edo helburu jakin baterako gaitasuna berme inplizitua ere izan gabe.\nIkus GNU Lizentzia Publiko Orokorra xehetasun gehiagorako.\n\n izan beharko zenuke GNU Lizentzia Publiko Orokorraren kopia programa honekin batera; bestela, idatzi Free Software Foundation-en, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, AEB, edo [https://www.gnu.org/copyleft/gpl.html irakurri ezazu online]. .", + "config-welcome-section-copyright": "=== Copyright eta terminoak ===\n\n$1\n\nPrograma hau software librea da; birbana eta / edo alda dezakezu GNU Lizentzia Publiko Orokorraren baldintzapean, Free Software Foundation-ek argitaratutakoaren arabera; Lizentziaren 2. bertsioa edo (nahiago baduzu) bertsio berriago bat.\n\nPrograma hau baliagarria izango delakoan elkarbantzen da, baina bermerik gabe ; merkaturatze edo helburu jakin baterako gaitasuna berme inplizitua ere izan gabe.\nIkus GNU Lizentzia Publiko Orokorra xehetasun gehiagorako.\n\n[$2 izan beharko zenuke GNU Lizentzia Publiko Orokorraren kopia] programa honekin batera; bestela, idatzi Free Software Foundation-en, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, AEB, edo [https://www.gnu.org/copyleft/gpl.html irakurri ezazu online]. .", "config-sidebar": "* [https://www.mediawiki.org MediaWiki nagusia]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Erabiltzaileentzako Gida]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Administratzaileentzako Gida]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MEG]\n----\n* Irakur nazazu\n* Oharren argitalpena\n* Kopiaketa\n* Eguneratzea", "config-env-good": "Ingurumena egiaztatu egin da. \nMediaWiki instalatu ahal duzu.", "config-env-bad": "Ingurumena egiaztatu egin da.\nEzin duzu MediaWiki-a instalatu.", @@ -163,9 +163,6 @@ "config-db-web-no-create-privs": "Zehaztu duzun kontuak ez dauka pribilegio nahikoak kontu bat sortzeko.\nZehaztu duzun kontua existitu behar da.", "config-mysql-engine": "Biltegiratze motorea:", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "Oharra: MyISAM MySQL biltegiratze-motor gisa aukeratu duzu, MediaWikirekin erabiltzeko gomendagarria ez dena honengatik:\n*taula blokeoak direla-eta gauza gutxi onartu ohi du\n*beste motore batzuek baino ustelkeria gehiago izateko aukerak ditu\n*MediaWiki-ren kode baseak ez du beti kudeatzen MyISAM behar bezala\n\nZure MySQL instalazioa InnoDB onartzen badu, hori aukeratzeko gomendatzen da.\nZure MySQL instalazioa InnoDB ez badu onartzen, baliteke bertsioa berritzeko ordua izatea.", - "config-mysql-only-myisam-dep": " Oharra: MyISAM makinaren MySQL biltegiratze motarako bakarra da, eta hau ez da MediaWiki-rekin erabiltzeko gomendatzen, honengatik:\n* maiztasunez taula blokeoek konkurrentzia ez dute onartzen \n* Beste motore batzuek baino ustelkeria gehiago izaten dute\n* MediaWiki-ren kodekak ez du beti kudeatzen MyISAM behar bezala\n\nZure MySQL instalazioak ez du InnoDB onartzen, agian bertsio berritzeko ordua da.", "config-mysql-engine-help": "InnoDB ia beti aukerarik onena da, konkurrentzia-laguntza ona duelako.\n\nMyISAM erabiltzaile bakarreko edo irakurketa bakarreko instalazioetan azkarragoa izan daiteke.\nMyISAM datu-basea gehiagokotan hondatuta ageri da InnoDB datu-baseareakin baino.", "config-mssql-auth": "Autentifikazio mota:", "config-mssql-install-auth": "Aukeratu instalazio prozesuan zehar datu-basera konektatzeko erabiliko den autentifikazio mota.\n\"{{Int: config-mssql-windowsauth}}\" hautatzen baduzu, web zerbitzariak duen edozein erabiltzailek erabiliko duen kredentziala erabiliko da.", diff --git a/includes/installer/i18n/fa.json b/includes/installer/i18n/fa.json index b17b0936c5..7cac681f2c 100644 --- a/includes/installer/i18n/fa.json +++ b/includes/installer/i18n/fa.json @@ -17,7 +17,8 @@ "Alifakoor", "Seb35", "Ahmad252", - "FarsiNevis" + "FarsiNevis", + "กิ๊ฟ เลิกล่ะ สายแข็ง" ] }, "config-desc": "نصب‌کنندهٔ مدیاویکی", @@ -57,8 +58,8 @@ "config-help-restart": "آیا می‌خواهید همهٔ اطلاعات ذخیره شده‌ای که وارد کرده‌اید را پاک کنید و دوباره روند نصب را شروع کنید؟", "config-restart": "بله، دوباره شروع کن", "config-welcome": "===بررسی‌های محیطی===\nبرای فهمیدن اینکه این محیط برای نصب مدیاویکی مناسب است، اکنون بررسی‌های اساسی انجام خواهد‌شد.\nاگر به دنبال پشتیبانی در چگونگی تکمیل نصب هستید،به یاد داشته باشید این اطلاعات را بگنجانید.", - "config-copyright": "=== حق رونوشت و شرایط ===\n\n$1\n\nاین برنامه، نرم‌افزاری آزاد است. می‌توانید تحت شرایط نگارش ۲ یا (بنا به نظر خود) هر نگارش جدیدتری از پروانهٔ جامع همگانی گنو که توسط بنیاد نرم‌افزار آزاد منتشر شده، بازنشرش کرده و/یا تغییرش دهید.\n\n\nاین برنامه با این امید توزیع شده که مفید باشد، ولی بدون هیچ ضمانتی، حتا ضمانت ضمنی معامله‌پذیری یا تناسب برای کاربردی خاص .\n\nبرای جزئیات بیشتر، پروانهٔ جامع همگانی گنو را ببینید.\n\n\nباید همراه این برنامه، نگارشی از پروانهٔ جامع همگانی گنو را گرفته باشید. اگر چنین نیست، با بنیاد نرم‌افزار آزاد به نشانی 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA مکاتبه کرده یا [https://www.gnu.org/copyleft/gpl.html پروانه را برخط بخوانید].", - "config-sidebar": "* [https://www.mediawiki.org صفحهٔ اصلی مدیاویکی]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents راهنمای کاربر]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents راهنمای مدیر]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ پرسش‌های رایج]\n----\n* مرا بخوان\n* یادداشت‌های انتشار\n* نسخه برداری\n* ارتقا", + "config-welcome-section-copyright": "=== حق رونوشت و شرایط ===\n\n$1\n\nاین برنامه، نرم‌افزاری آزاد است. می‌توانید تحت شرایط نگارش ۲ یا (بنا به نظر خود) هر نگارش جدیدتری از پروانهٔ جامع همگانی گنو که توسط بنیاد نرم‌افزار آزاد منتشر شده، بازنشرش کرده و/یا تغییرش دهید.\n\n\nاین برنامه با این امید توزیع شده که مفید باشد، ولی بدون هیچ ضمانتی، حتا ضمانت ضمنی معامله‌پذیری یا تناسب برای کاربردی خاص .\n\nبرای جزئیات بیشتر، پروانهٔ جامع همگانی گنو را ببینید.\n\n\nباید همراه این برنامه، [$2 نگارشی از پروانهٔ جامع همگانی گنو] را گرفته باشید. اگر چنین نیست، با بنیاد نرم‌افزار آزاد به نشانی 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA مکاتبه کرده یا [https://www.gnu.org/copyleft/gpl.html پروانه را برخط بخوانید].", + "config-sidebar": "* [//www.mediawiki.org صفحهٔ اصلی مدیاویکی]\n* [//www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents راهنمای کاربر]\n* [//www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents راهنمای مدیر]\n* [//www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ پرسش‌های رایج]\n----\n* مرا بخوان\n* یادداشت‌های انتشار\n* نسخه برداری\n* ارتقا", "config-env-good": "محیط بررسی شده‌است.\nشما می‌توانید مدیاویکی را نصب کنید.", "config-env-bad": "محیط بررسی شده‌است.\nشما نمی‌توانید مدیاویکی را نصب کنید.", "config-env-php": "پی‌اچ‌پی $1 نصب شده‌است.", @@ -172,9 +173,6 @@ "config-db-web-no-create-privs": "حسابی که شما برای نصب تعیین کردید،مزایای کافی برای ایجاد یک حساب را ندارد.\nحسابی که شما اینجا تعیین کرده‌اید باید در حال حاضر وجود داشته باشد.", "config-mysql-engine": "موتور ذخیره سازی:", "config-mysql-innodb": "اینودی‌بی (پیشنهاد می‌شود)", - "config-mysql-myisam": "می‌ای‌سم", - "config-mysql-myisam-dep": "'''هشدار:''' شما مای‌آی‌اس‌ای‌ام را به عنوان موتور ذخیره برای مای‌آی‌اس‌ای‌ام انتخاب کرده‌اید، که برای استفاده با مدیاویکی توصیه نمی‌شود زیرا:\n* به‌علت قفل شدن جدول اجمالاً به طور همزمان پشتیبانی می کند\n* بیشتر از دیگر موتورها برای از بین‌ رفتن مستعد است.\n* مبنای رمز مدیاویکی همیشه مای‌آی‌اس‌ای‌ام را همان طور که باید باشد،کنترل نمی‌کند\nاگر نصب مای‌اس‌کیو‌ال شما اینودی‌بی را پشتیبانی می‌کند،بسیار توصیه می‌شود که در عوض ،آن را انتخاب کنید.\nاگر نصب مای‌اس‌کیو‌ال شما، اینودی‌بی را پشتیبانی نمی‌کند، ممکن است زمان ارتقاء رسیده باشد.", - "config-mysql-only-myisam-dep": "'''هشدار:''' مای‌آی‌اس‌ای‌ام تنها موتور ذخیره‌سازی اطلاعات برای مای‌اس‌کیو‌ال در این دستگاه است، و برای استفاده با مدیاویکی توصیه نمی‌شود، زیرا:\n* به‌علت قفل شدن جدول اجمالاً به طور همزمان پشتیبانی می کند\n* بیشتر از دیگر موتورها برای از بین‌ رفتن مستعد است.\n* مبنای رمز مدیاویکی همیشه مای‌آی‌اس‌ای‌ام را همان طور که باید باشد،کنترل نمی‌کند\nنصب مای‌اس‌کیو‌ال شما اینودی‌بی را پشتیبانی نمی‌کند،ممکن است زمان یک ارتقاء رسیده باشد.", "config-mysql-engine-help": "'''اینودی‌بی''' تقریباً همیشه بهترین گزینه است،زیرا پشتیبانی همزمان خوبی دارد.\n'''مای‌آی‌اس‌ای‌ام''' ممکن است در نصب‌های کاربر جداگانه یا فقط خواندنی سریع‌تر باشد.\nپایگاه‌های اطلاعاتی مای‌آی‌اس‌ای‌ام اغلب بیشتر از پایگاه‌های اطلاعاتی اینودی‌بی مستعد ازبین رفتن هستند.", "config-mssql-auth": "نوع تأیید:", "config-mssql-install-auth": "نوع تأییدی را که برای اتصال به پایگاه اطلاعاتی حین فرآیند نصب مورد استفاده قرار گیرد را انتخاب کنید.\nاگر \"{{int:config-mssql-windowsauth}}\" را انتخاب می‌کنید، اعتبارات هرآنچه کاربر وب سرور به عنوان آن مورد استفاده قرار می‌دهد مورد استفاده قرار خواهد گرفت.", @@ -304,7 +302,7 @@ "config-install-stats": "شروع آمار", "config-install-keys": "تولید کلیدهای مخفی", "config-install-updates": "جلوگیری از به روز رسانی‌های غیر ضروری در حال اجرا", - "config-install-updates-failed": "خطا: قراردادن کلیدهای به روز رسانی به داخل جداول با خطای روبرو مواجه شد: $1", + "config-install-updates-failed": "خطا: قرار دادن کلیدهای روزآمدسازی در جدول‌ها با شکست و این خطا مواجه شد: $1", "config-install-sysop": "ایجاد حساب کاربری مدیر", "config-install-subscribe-fail": "قادر تصدیق اعلام مدیاویکی نیست:$1", "config-install-subscribe-notpossible": "سی‌یوآر‌ال نصب نشده‌است و allow_url_fopen در دسترس نیست.", diff --git a/includes/installer/i18n/fi.json b/includes/installer/i18n/fi.json index fbc80f8e2e..8c338f85c7 100644 --- a/includes/installer/i18n/fi.json +++ b/includes/installer/i18n/fi.json @@ -63,7 +63,7 @@ "config-help-restart": "Haluatko poistaa kaikki annetut tiedot ja aloittaa asennuksen alusta?", "config-restart": "Kyllä", "config-welcome": "=== Ympäristön tarkistukset ===\nVarmistetaan MediaWikin asennettavuus tähän ympäristöön.\nMuista antaa nämä tiedot, jos tarvitset apua asennuksen aikana.", - "config-copyright": "=== Tekijänoikeudet ja käyttöehdot ===\n\n$1\n\nTämä ohjelma on vapaa ohjelmisto; voit levittää sitä ja/tai muokata sitä Free Software Foundationin GNU General Public Licensen ehdoilla, joko version 2 tai (halutessasi) minkä tahansa myöhemmän version mukaisesti.\n\nTätä ohjelmaa levitetään siinä toivossa, että se olisi hyödyllinen, mutta ilman mitään takuuta; ilman edes hiljaista takuuta kaupallisesti hyväksyttävästä laadusta tai soveltuvuudesta tiettyyn tarkoitukseen.kopio GNU General Public Licensestä tämän ohjelman mukana; jos et, kirjoita siitä osoitteeseen Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA tai [https://www.gnu.org/copyleft/gpl.html lue se verkossa].", + "config-welcome-section-copyright": "=== Tekijänoikeudet ja käyttöehdot ===\n\n$1\n\nTämä ohjelma on vapaa ohjelmisto; voit levittää sitä ja/tai muokata sitä Free Software Foundationin GNU General Public Licensen ehdoilla, joko version 2 tai (halutessasi) minkä tahansa myöhemmän version mukaisesti.\n\nTätä ohjelmaa levitetään siinä toivossa, että se olisi hyödyllinen, mutta ilman mitään takuuta; ilman edes hiljaista takuuta kaupallisesti hyväksyttävästä laadusta tai soveltuvuudesta tiettyyn tarkoitukseen.Lue minut\n* Julkaisutiedot\n* Kopiointi\n* Päivittäminen", "config-env-good": "Asennusympäristö on tarkastettu.\nVoit asentaa MediaWikin.", "config-env-bad": "Asennusympäristö on tarkastettu.\nEt voi asentaa MediaWikiä.", @@ -171,7 +171,6 @@ "config-db-web-no-create-privs": "Tilillä jota käytetään asennuksessa ei ole oikeuksia luoda uutta tiliä.\nTähän määriteltävä tili täytyy olla jo olemassa.", "config-mysql-engine": "Tallennusmoottori", "config-mysql-innodb": "InnoDB (suositeltu)", - "config-mysql-myisam": "MyISAM", "config-mssql-auth": "Varmennuksen tyyppi:", "config-mssql-install-auth": "Valitse varmennuksen tyyppi, jota käytetään yhdistäessä tietokantaan asennuksen aikana.\nJos valitset \"{{int:config-mssql-windowsauth}}\", käytetään verkkopalvelimen käyttäjän kirjautumistietoja.", "config-mssql-web-auth": "Valitse varmennuksen tyyppi, jota verkkopalvelin käyttää yhdistäessään tietokantapalvelimeen wikin tavallisen toiminnan aikana.\nJos valitset \"{{int:config-mssql-windowsauth}}\", käytetään verkkopalvelimen käyttäjän kirjautumistietoja.", diff --git a/includes/installer/i18n/fr.json b/includes/installer/i18n/fr.json index 623e624375..b07c83e493 100644 --- a/includes/installer/i18n/fr.json +++ b/includes/installer/i18n/fr.json @@ -71,14 +71,18 @@ "config-help-restart": "Voulez-vous effacer toutes les données enregistrées que vous avez entrées et relancer le processus d'installation ?", "config-restart": "Oui, le relancer", "config-welcome": "=== Vérifications liées à l’environnement ===\nDes vérifications de base vont maintenant être effectuées pour voir si cet environnement est adapté à l’installation de MediaWiki.\nRappelez-vous d’inclure ces informations si vous recherchez de l’aide sur la manière de terminer l’installation.", - "config-copyright": "=== Droit d’auteur et conditions ===\n\n$1\n\nCe programme est un logiciel libre : vous pouvez le redistribuer ou le modifier selon les termes de la Licence Publique Générale GNU telle que publiée par la Free Software Foundation (version 2 de la Licence, ou, à votre choix, toute version ultérieure).\n\nCe programme est distribué dans l’espoir qu’il sera utile, mais '''sans aucune garantie''' : sans même les garanties implicites de '''commercialisabilité''' ou d’'''adéquation à un usage particulier'''.\nVoir la Licence Publique Générale GNU pour plus de détails.\n\nVous devriez avoir reçu une copie de la Licence Publique Générale GNU avec ce programme ; dans le cas contraire, écrivez à la Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. ou [https://www.gnu.org/copyleft/gpl.html lisez-la en ligne].", - "config-sidebar": "* [https://www.mediawiki.org Accueil MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Guide de l’utilisateur]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Guide de l’administrateur]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]\n----\n* Lisez-moi\n* Notes de publication\n* Copie\n* Mise à jour", + "config-welcome-section-copyright": "=== Droit d’auteur et conditions ===\n\n$1\n\nCe programme est un logiciel libre : vous pouvez le redistribuer ou le modifier selon les termes de la Licence Publique Générale GNU telle que publiée par la Free Software Foundation (version 2 de la Licence, ou, à votre choix, toute version ultérieure).\n\nCe programme est distribué dans l’espoir qu’il sera utile, mais '''sans aucune garantie''' : sans même les garanties implicites de '''commercialisabilité''' ou d’'''adéquation à un usage particulier'''.\nVoir la Licence Publique Générale GNU pour plus de détails.\n\nVous devriez avoir reçu [$2 une copie de la Licence Publique Générale GNU] avec ce programme ; dans le cas contraire, écrivez à la Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. ou [https://www.gnu.org/copyleft/gpl.html lisez-la en ligne].", + "config-sidebar": "* [https://www.mediawiki.org Accueil MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Guide de l’utilisateur]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Guide de l’administrateur]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]", + "config-sidebar-readme": "Me lire", + "config-sidebar-relnotes": "Notes de version", + "config-sidebar-license": "Copie", + "config-sidebar-upgrade": "Mise à jour", "config-env-good": "L’environnement a été vérifié.\nVous pouvez installer MediaWiki.", "config-env-bad": "L’environnement a été vérifié.\nVous ne pouvez pas installer MediaWiki.", "config-env-php": "PHP $1 est installé.", "config-env-hhvm": "HHVM $1 est installé.", - "config-unicode-using-intl": "Utilisation de [https://pecl.php.net/intl l’extension PECL intl] pour la normalisation Unicode.", - "config-unicode-pure-php-warning": "Attention : L’[https://pecl.php.net/intl extension PECL intl] n’est pas disponible pour la normalisation d’Unicode, retour à la version lente implémentée en PHP seulement.\nSi votre site web sera très fréquenté, vous devriez lire ceci : [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations ''Unicode normalization''] (en anglais).", + "config-unicode-using-intl": "Utilisation de [https://php.net/manual/en/book.intl.php extension intl de PHP] pour la normalisation Unicode.", + "config-unicode-pure-php-warning": "Attention : L’[https://php.net/manual/en/book.intl.php extension intl de PHP] n’est pas disponible pour la normalisation d’Unicode, retour à la version lente implémentée en PHP seulement.\nSi votre site web sera très fréquenté, vous devriez lire ceci : [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations ''Unicode normalization''] (en anglais).", "config-unicode-update-warning": "Attention : la version installée du normalisateur Unicode utilise une ancienne version de la bibliothèque logicielle du [http://site.icu-project.org/ ''Projet ICU''].\nVous devriez faire une [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations mise à jour] si vous êtes concerné par l’usage d’Unicode.", "config-no-db": "Impossible de trouver un pilote de base de données approprié ! Vous devez installer un pilote de base de données pour PHP. {{PLURAL:$2|Le type suivant|Les types suivants}} de bases de données {{PLURAL:$2|est reconnu|sont reconnus}} : $1.\n\nSi vous avez compilé PHP vous-même, reconfigurez-le avec un client de base de données activé, par exemple en utilisant ./configure --with-mysqli. \nSi vous avez installé PHP depuis un paquet Debian ou Ubuntu, alors vous devrez aussi installer, par exemple, le paquet php-mysql.", "config-outdated-sqlite": "Attention : vous avez SQLite $2, qui est inférieur à la version minimale requise $1. SQLite sera indisponible.", @@ -189,9 +193,6 @@ "config-db-web-no-create-privs": "Le compte que vous avez spécifié pour l'installation n'a pas de privilèges suffisants pour créer un compte.\nLe compte que vous spécifiez ici doit déjà exister.", "config-mysql-engine": "Moteur de stockage :", "config-mysql-innodb": "InnoDB (recommandé)", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": " Avertissement : vous avez sélectionné MyISAM comme moteur de stockage pour MySQL, ce qui n’est pas recommandé pour une utilisation avec MediaWiki, parce que :\n * il prend à peine en charge la simultanéité en raison de verrouillage de table\n * il est plus sujet à la corruption que les autres moteurs\n * le code de base MediaWiki ne gère pas toujours MyISAM comme il se doit\n\nSi votre installation MySQL prenden charge InnoDB, il est fortement recommandé que vous le choisissiez plutôt. \nSi votre installation MySQL ne prend pas en charge les tables InnoDB, il est peut-être temps de faire une mise à niveau.", - "config-mysql-only-myisam-dep": "Attention : MyISAM est le seul moteur de stockage disponible pour MySQL sur cette machine, et cela n’est pas recommandé pour une utilisation avec MédiaWiki, car :\n* il prend très peu en charge les accès concurrents à cause du verrouillage des tables\n* il est plus sujet à corruption que les autres moteurs\n* le code de base de MédiaWiki ne gère pas toujours MyISAM comme il faudrait\n\nVotre installation MySQL ne prend pas en charge InnoDB ; il est peut-être temps de la mettre à jour.", "config-mysql-engine-help": "InnoDB est presque toujours la meilleure option, car il prend bien en charge les accès concurrents.\n\nMyISAM peut être plus rapide dans les installations monoposte ou en lecture seule.\nLes bases de données MyISAM ont tendance à se corrompre plus souvent que les bases d’InnoDB.", "config-mssql-auth": "Type d’authentification :", "config-mssql-install-auth": "Sélectionner le type d’authentification qui sera utilisé pour se connecter à la base de données pendant le processus d’installation.\nSi vous sélectionnez « {{int:config-mssql-windowsauth}} », les informations d’identification de l’utilisateur faisant tourner le serveur seront utilisées.", diff --git a/includes/installer/i18n/frc.json b/includes/installer/i18n/frc.json index c0e86f7584..7126f90284 100644 --- a/includes/installer/i18n/frc.json +++ b/includes/installer/i18n/frc.json @@ -49,7 +49,6 @@ "config-invalid-db-type": "Type de base de données non valide", "config-sqlite-name-help": "Choisir un nom qui identifie ton wiki.\nFait user pas ni d'espaces ni des traits d'union\nIl va user pour fichier de données SQLite.", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", "config-site-name": "Nom du wiki:", "config-ns-generic": "Projet", "config-ns-other-default": "MonWiki", diff --git a/includes/installer/i18n/frp.json b/includes/installer/i18n/frp.json index 033692a09b..bc35fc24ac 100644 --- a/includes/installer/i18n/frp.json +++ b/includes/installer/i18n/frp.json @@ -67,7 +67,6 @@ "config-db-web-create": "Féte lo compto s’ègziste p’oncor", "config-mysql-engine": "Motor de stocâjo :", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", "config-site-name": "Nom du vouiqui :", "config-site-name-blank": "Buchiéd un nom de seto.", "config-project-namespace": "Èspâço de noms du projèt :", diff --git a/includes/installer/i18n/gl.json b/includes/installer/i18n/gl.json index 86c0c578d9..c75d129759 100644 --- a/includes/installer/i18n/gl.json +++ b/includes/installer/i18n/gl.json @@ -7,7 +7,8 @@ "Vivaelcelta", "Macofe", "Banjo", - "Seb35" + "Seb35", + "Maria zaos" ] }, "config-desc": "O programa de instalación de MediaWiki", @@ -47,7 +48,7 @@ "config-help-restart": "Quere eliminar todos os datos gardados e reiniciar o proceso de instalación?", "config-restart": "Si, reiniciala", "config-welcome": "=== Comprobación da contorna ===\nCómpre realizar agora unhas comprobacións básicas para ver se a contorna é axeitada para a instalación de MediaWiki.\nLembre incluír esta información se necesita axuda para completar a instalación.", - "config-copyright": "=== Dereitos de autor e termos de uso ===\n\n$1\n\nEste programa é software libre; pode redistribuílo e/ou modificalo segundo os termos da licenza pública xeral GNU publicada pola Free Software Foundation; versión 2 ou (na súa escolla) calquera outra posterior.\n\nEste programa distribúese coa esperanza de que poida ser útil, pero sen garantía ningunha; nin sequera a garantía implícita de comercialización ou adecuación a unha finalidade específica.\nOlle a licenza pública xeral GNU para obter máis detalles.\n\nDebería recibir unha copia da licenza pública xeral GNU xunto ao programa; se non é así, escriba á Free Software Foundation, Inc., rúa Franklin, número 51, quinto andar, Boston, Massachusetts, 02110-1301, Estados Unidos de América ou [https://www.gnu.org/copyleft/gpl.html lea a licenza en liña].", + "config-welcome-section-copyright": "=== Dereitos de autoría e termos de uso ===\n\n$1\n\nEste programa é software libre; pode redistribuílo e/ou modificalo segundo os termos da licenza pública xeral GNU publicada pola Free Software Foundation; versión 2 ou (na súa escolla) calquera outra posterior.\n\nEste programa distribúese coa esperanza de que poida ser útil, pero sen garantía ningunha; nin sequera a garantía implícita de comercialización ou adecuación a unha finalidade específica.\nOlle a licenza pública xeral GNU para obter máis detalles.\n\nDebería recibir [$2 unha copia da licenza pública xeral GNU] xunto ao programa; se non é así, escriba á Free Software Foundation, Inc., rúa Franklin, número 51, quinto andar, Boston, Massachusetts, 02110-1301, Estados Unidos de América ou [https://www.gnu.org/copyleft/gpl.html lea a licenza en liña].", "config-sidebar": "* [https://www.mediawiki.org/wiki/MediaWiki/gl Páxina principal de MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Guía de usuario]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Guía de administrador]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Preguntas máis frecuentes]\n----\n* Léame\n* Notas de lanzamento\n* Copia\n* Actualizacións", "config-env-good": "Rematou a comprobación da contorna.\nPode instalar MediaWiki.", "config-env-bad": "Rematou a comprobación da contorna.\nNon pode instalar MediaWiki.", @@ -164,9 +165,6 @@ "config-db-web-no-create-privs": "A conta que especificou para a instalación non ten os privilexios suficientes para crear unha conta.\nA conta que se especifique aquí xa debe existir.", "config-mysql-engine": "Motor de almacenamento:", "config-mysql-innodb": "InnoDB (recomendado)", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "Atención: Seleccionou MyISAM como o motor de almacenamento para MySQL, unha combinación non recomendada para MediaWiki, porque:\n* practicamente non soporta os accesos simultáneos debido ao bloqueo de táboas\n* é máis propenso a corromperse ca outros motores\n* o código base de MediaWiki non sempre manexa o MyISAM como debera\n\nSe a súa instalación MySQL soporta InnoDB, recoméndase elixilo no canto de MyISAM.\nSe a súa instalación MySQL non soporta InnoDB, quizais sexa boa idea realizar unha actualización.", - "config-mysql-only-myisam-dep": "Atención: MyISAM é o único motor de almacenamento para MySQL nesta máquina, unha combinación non recomendada para MediaWiki, porque:\n* practicamente non soporta os accesos simultáneos debido ao bloqueo de táboas\n* é máis propenso a corromperse ca outros motores\n* o código base de MediaWiki non sempre manexa o MyISAM como debera\n\nA súa instalación MySQL non soporta InnoDB, quizais sexa boa idea realizar unha actualización.", "config-mysql-engine-help": "InnoDB é case sempre a mellor opción, dado que soporta ben os accesos simultáneos.\n\nMyISAM é máis rápido en instalacións de usuario único e de só lectura.\nAs bases de datos MyISAM tenden a se corromper máis a miúdo ca as bases de datos InnoDB.", "config-mssql-auth": "Tipo de autenticación:", "config-mssql-install-auth": "Seleccione o tipo de autenticación que se utilizará para conectarse á base de datos durante o proceso de instalación.\nSe selecciona \"{{int:config-mssql-windowsauth}}\", usaranse as credenciais do usuario co que se está a executar o servidor web.", diff --git a/includes/installer/i18n/gsw.json b/includes/installer/i18n/gsw.json index 337a179a12..a9d0c1ccac 100644 --- a/includes/installer/i18n/gsw.json +++ b/includes/installer/i18n/gsw.json @@ -36,7 +36,7 @@ "config-help-restart": "Witt alli Date, wu Du yygee hesch, lesche un d Inschtallation nomol aafange?", "config-restart": "Jo, nomol aafange", "config-welcome": "=== Priefig vu dr Inschtallationsumgäbig ===\nBasispriefige wäre durgfiert zum Feschtstelle, eb d Inschtallationsumgäbig fir d Inschtallation vu MediaWiki geignet isch.\nDu sottsch d Ergebnis vu däre Priefig aagee, wänn Du bi dr Inschtallation Hilf bruchsch.", - "config-copyright": "=== Copyright un Nutzigsbedingige ===\n\n$1\n\nDes Programm isch e freji Software, d. h. s cha, no dr Bedingige vu dr GNU General Public-Lizänz, wu vu dr Free Software Foundation vereffentligt woren isch, wyterverteilt un/oder modifiziert wäre. Doderbyy cha d Version 2, oder no eigenem Ermässe, jedi nejeri Version vu dr Lizänz brucht wäre.\n\nDes Programm wird in dr Hoffnig verteilt, ass es nitzli isch, aber '''ohni jedi Garanti''' un sogar ohni di impliziert Garanti vun ere '''Märtgängigkeit''' oder '''Eignig fir e bstimmte Zwäck'''. Doderzue git meh Hiiwys in dr GNU General Public-Lizänz.\n\nE Kopi vu dr GNU General Public-Lizänz sott zämme mit däm Programm verteilt wore syy. Wänn des nit eso isch, cha ne Kopi bi dr Free Software Foundation Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, schriftli aagforderet oder [https://www.gnu.org/copyleft/gpl.html online gläse] wäre.", + "config-welcome-section-copyright": "=== Copyright un Nutzigsbedingige ===\n\n$1\n\nDes Programm isch e freji Software, d. h. s cha, no dr Bedingige vu dr GNU General Public-Lizänz, wu vu dr Free Software Foundation vereffentligt woren isch, wyterverteilt un/oder modifiziert wäre. Doderbyy cha d Version 2, oder no eigenem Ermässe, jedi nejeri Version vu dr Lizänz brucht wäre.\n\nDes Programm wird in dr Hoffnig verteilt, ass es nitzli isch, aber '''ohni jedi Garanti''' un sogar ohni di impliziert Garanti vun ere '''Märtgängigkeit''' oder '''Eignig fir e bstimmte Zwäck'''. Doderzue git meh Hiiwys in dr GNU General Public-Lizänz.\n\nE [$2 Kopi vu dr GNU General Public-Lizänz] sott zämme mit däm Programm verteilt wore syy. Wänn des nit eso isch, cha ne Kopi bi dr Free Software Foundation Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, schriftli aagforderet oder [https://www.gnu.org/copyleft/gpl.html online gläse] wäre.", "config-sidebar": "* [https://www.mediawiki.org MediaWiki Websyte vu MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Nutzeraaleitig zue MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Adminischtratoreaaleitig zue MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Vilmol gstellti Froge zue MediaWiki]", "config-env-good": "D Inschtallationsumgäbig isch prieft wore.\nDu chasch MediaWiki inschtalliere.", "config-env-bad": "D Inschtallationsumgäbigisch prieft wore.\nDu chasch MediaWiki nit inschtalliere.", diff --git a/includes/installer/i18n/he.json b/includes/installer/i18n/he.json index 08e56e088c..7d361bc219 100644 --- a/includes/installer/i18n/he.json +++ b/includes/installer/i18n/he.json @@ -51,7 +51,7 @@ "config-help-restart": "האם ברצונך לנקות את כל הנתונים שהזנת ולהתחיל מחדש את תהליך ההתקנה?", "config-restart": "כן, להפעיל מחדש", "config-welcome": "=== בדיקות סביבה ===\nבדיקות בסיסיות תתבצענה עכשיו כדי לראות אם הסביבה הזאת מתאימה להתקנת מדיה־ויקי.\nנא לזכור לכלול את המידע הזה בעת בקשת תמיכה עם השלמת ההתקנה.", - "config-copyright": "=== זכויות יוצרים ותנאים ===\n\n$1\n\nתכנית זו היא תכנה חופשית; באפשרותך להפיצה מחדש ו/או לשנות אותה על פי תנאי הרישיון הציבורי הכללי של GNU כפי שפורסם על ידי קרן התכנה החופשית; בין אם גרסה 2 של הרישיון, ובין אם (לפי בחירתך) כל גרסה מאוחרת שלו.\n\nתכנית זו מופצת בתקווה שתהיה מועילה, אבל '''בלא אחריות כלשהי'''; ואפילו ללא האחריות המשתמעת בדבר '''מסחריותה''' או '''התאמתה למטרה '''מסוימת'''. לפרטים נוספים, ניתן לעיין ברישיון הציבורי הכללי של GNU.\n\nלתכנית זו אמור היה להיות מצורף עותק של הרישיון הציבורי הכללי של GNU; אם לא קיבלת אותו, אפשר לכתוב ל־Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA או [https://www.gnu.org/copyleft/gpl.html לקרוא אותו דרך האינטרנט].", + "config-welcome-section-copyright": "=== זכויות יוצרים ותנאים ===\n\n$1\n\nתכנית זו היא תכנה חופשית; באפשרותך להפיצה מחדש ו/או לשנות אותה על פי תנאי הרישיון הציבורי הכללי של GNU כפי שפורסם על ידי קרן התכנה החופשית; בין אם גרסה 2 של הרישיון, ובין אם (לפי בחירתך) כל גרסה מאוחרת שלו.\n\nתכנית זו מופצת בתקווה שתהיה מועילה, אבל '''בלא אחריות כלשהי'''; ואפילו ללא האחריות המשתמעת בדבר '''מסחריותה''' או '''התאמתה למטרה '''מסוימת'''. לפרטים נוספים, ניתן לעיין ברישיון הציבורי הכללי של GNU.\n\nלתכנית זו אמור היה להיות מצורף [$2 עותק של הרישיון הציבורי הכללי של GNU]; אם לא קיבלת אותו, אפשר לכתוב ל־Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA או [https://www.gnu.org/copyleft/gpl.html לקרוא אותו דרך האינטרנט].", "config-sidebar": "* [https://www.mediawiki.org אתר הבית של מדיה־ויקי]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents המדריך למשתמש]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents המדריך למנהל]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ שו״ת]\n----\n* קרא אותי\n* הערות גרסה\n* העתקה\n* שדרוג", "config-env-good": "הסביבה שלכם נבדקה.\nאפשר להתקין מדיה־ויקי.", "config-env-bad": "הסביבה שלכם נבדקה.\nאי־אפשר להתקין מדיה־ויקי.", @@ -165,9 +165,6 @@ "config-db-web-no-create-privs": "לחשבון שהקלדת להתקנה אין מספיק הרשאות ליצירת חשבון.\nהחשבון שאתם מקלידים כאן צריך להיות קיים.", "config-mysql-engine": "מנוע האחסון:", "config-mysql-innodb": "InnoDB (מומלץ)", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "'''אזהרה''': בחרתם ב־MyISAM בתור מנוע אחסון של MySQL, וזה לא מומלץ מהסיבות הבאות:\n* המנוע הזה בקושי תומך בעיבוד מקבילי בגלל נעילת טבלאות\n* הוא פחות עמיד בפני אובדן מידע ממנועים אחרים\n* הקוד של מדיה־ויקי לא תמיד מטפל ב־MyISAM כפי שצריך\n\nאם התקנת MySQL שלכם תומכת ב־InnoDB, מומלץ מאוד שתבחרו באפשרות הזאת.\nאם התקנת MySQL שלכם אינה תומכת ב־InnoDB, אולי זה הזמן לשקול לשדרג אותה.", - "config-mysql-only-myisam-dep": "'''אזהרה:''' MyISAM הוא מנוע האחסון היחיד שזמין ל־MySQL במכונה הזאת, וזה לא מומלץ לשימוש עם מדיה־ויקי, כי:\n* הוא כמעט שאינו תומך בחיבורים מרובים בגלל נעילת טבלאות\n* הוא פגיע יותר לקלקול ממנועים אחרים\n* הקוד של מדיה־ויקי לא תמיד מטפל ב־MyISAM כפי שצריך\n\nהתקנת MySQL אינה תומכת ב־InnoDB, ואולי הגיע הזמן לשדרג אותה.", "config-mysql-engine-help": "'''InnoDB''' היא כמעט תמיד האפשרות הטובה ביותר, כי במנוע הזה יש תמיכה טובה ביותר בעיבוד מקבילי.\n\n'''MyISAM''' עשוי להיות בהתקנות שמיועדות למשתמש אחד ולהתקנות לקריאה בלבד.\nמסדי נתונים עם MyISAM נוטים להיהרס לעתים קרובות יותר מאשר מסדי נתונים עם InnoDB.", "config-mssql-auth": "סוג אימות:", "config-mssql-install-auth": "נא לבחור את סוג האימות שישמש להתחברות למסד הנתונים בזמן תהליך ההתקנה. בחירה ב־\"{{int:config-mssql-windowsauth}}\" תשתמש בהרשאות של החשבון שמריץ את השרת הנוכחי.", diff --git a/includes/installer/i18n/hrx.json b/includes/installer/i18n/hrx.json index 75a76b1ce0..191460076c 100644 --- a/includes/installer/i18n/hrx.json +++ b/includes/installer/i18n/hrx.json @@ -42,7 +42,7 @@ "config-help-restart": "Solle all bereits ingebne Daten gelöscht und der Installationsvoargang erneit oogefäng sin?", "config-restart": "Jo, erneit oonfänge", "config-welcome": "=== Prüfung von die Installationsumgebung ===\nDie Basisprüfunge were jetzt doorrichgefüahrt, um festzustelle, ob die Installationsumgebung für MediaWiki geeichnet ist.\nNotier die Informatione und geb se an, sofern du Hellf beim Installiere benötichst.", - "config-copyright": "=== Lizenz und Nutzungsbedingunge ===\n\n$1\n\nDas Programm ist freie Software, d. h. es kann, gemäss den Bedingunge der von der Free Software Foundation veröffentlichte ''GNU General Public License'', weiterverteilt und/oder modifiziert sin. Dabei kann die Version 2, orrer noh eichnem Ermess, jede neuire Version von der Lizenz verwennet sin.\n\nDas Programm weard in der Hoffnung verteilt, dass das nützlich sein weard, dennoch '''ohne jechliche Garantie''' und sogoor ohne die implizierte Garantie von ener '''Marrektgängigkeit''' orrer '''Eichnung für en bestimmte Zweck'''. Hierzu sind weitre Hinweise in der ''GNU General Public License'' enthalt.\n\nEn Kopie von der GNU General Public License sollt zusammer mit dem Programm verteilt woard sin. Sofern das net der Fall woar, kann en Kopie bei der Free Software Foundation Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, schriftlich verlangt sin orrer uff ehre Website [https://www.gnu.org/copyleft/gpl.html online gelesen] sin.", + "config-welcome-section-copyright": "=== Lizenz und Nutzungsbedingunge ===\n\n$1\n\nDas Programm ist freie Software, d. h. es kann, gemäss den Bedingunge der von der Free Software Foundation veröffentlichte ''GNU General Public License'', weiterverteilt und/oder modifiziert sin. Dabei kann die Version 2, orrer noh eichnem Ermess, jede neuire Version von der Lizenz verwennet sin.\n\nDas Programm weard in der Hoffnung verteilt, dass das nützlich sein weard, dennoch '''ohne jechliche Garantie''' und sogoor ohne die implizierte Garantie von ener '''Marrektgängigkeit''' orrer '''Eichnung für en bestimmte Zweck'''. Hierzu sind weitre Hinweise in der ''GNU General Public License'' enthalt.\n\nEn [$2 Kopie von der GNU General Public License] sollt zusammer mit dem Programm verteilt woard sin. Sofern das net der Fall woar, kann en Kopie bei der Free Software Foundation Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, schriftlich verlangt sin orrer uff ehre Website [https://www.gnu.org/copyleft/gpl.html online gelesen] sin.", "config-sidebar": "* [https://www.mediawiki.org/wiki/MediaWiki/de Website von MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents/de Benutzeroonleitung]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents/de Administratorenoonleitung]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/de Häifig gestellte Frache]\n----\n* Lies mich\n* Versionsinformatione\n* Lizenzbestimmunge\n* Aktualisierung", "config-env-good": "Die Installationsumgebung woard geprüft.\nMediaWiki kann installiert sin.", "config-env-bad": "Die Installationsumgebung woard geprüft.\nMediaWiki kann net installiert sin.", @@ -152,9 +152,6 @@ "config-db-web-no-create-privs": "Das oongebne und für den Installationsvoargang voargesiehne Datebankkonto verfücht nwt üwer ausreichend Berechtichunge, um en weitres Datebankkonto zu erstelle.\nDas hier oongebne Datebankkonto muss dohear bereits voarhand sin.", "config-mysql-engine": "Speicher-Engine:", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "'''Warnung:''' Es woard MyISAM als Speicher-Engine für MySQL ausgewählt, die aus follichend Gründe net für den Insatz mit MediaWiki rekommendiert ist:\n* Sie unnerstützt uffgrund von Tabellesperrunge koom die neweloofiche Ausführung von Aktione.\n* Sie ist oonfällicher für Dateprobleme.\n* Sie weard von MediaWiki net immer adäquat unnerstützt.\n\nSoweit die voarhandne MySQL-Installation die Speicher-Engine InnoDB unnerstützt, weard sei Verwennung eindringlich rekommendiert.\nSoweit sie sie net unnerstützt, sollt en entsprechend Aktualisierung nunmeahr Erwächung gezoh sin.", - "config-mysql-only-myisam-dep": "'''Warnung:''' MyISAM ist die einziche verfüchbare Speicher-Engine für MySQL uff dem Rechner, und das weard net für die Verwennung mit MediaWiki rekommendiert, weil sie\n* uffgrund von Tabellesperrunge koom die neweloofiche Ausführung von Aktione unnerstützt,\n* oonfällicher für Dateprobleme ist und\n* von MediaWiki net immer adäquat unnerstützt weard.\n\nDein MySQL-Installation unnerstützt net InnoDB. Eventuell muss en Aktualisierung dorrichgeführt werre.", "config-mysql-engine-help": "'''InnoDB''' ist nächst immer die bessre Wähl, weil es gleichzeitiche Zugriffe gut unnerstützt.\n\n'''MyISAM''' ist in Enzelnutzerumgebunge sowie bei schreibgeschützte Wikis schneller.\nBei MyISAM-Datebanke treten tendenziell häuficher Fehler uff als bei InnoDB-Datebanke.", "config-mssql-auth": "Authentifikationstyp:", "config-mssql-install-auth": "Wähl den Authentifikationstyp aus, der zur Verbinnung mit der Datebank während von der Installationsprozesses verwennt weard.\nFalls du „{{int:config-mssql-windowsauth}}“ auswählst, werre die Oonmeldeinformatione von en beliebiche Benutzer verwennt, wo den Webserver ausführt.", diff --git a/includes/installer/i18n/hsb.json b/includes/installer/i18n/hsb.json index f2b2a7f2bc..5a5f109263 100644 --- a/includes/installer/i18n/hsb.json +++ b/includes/installer/i18n/hsb.json @@ -125,7 +125,6 @@ "config-db-web-no-create-privs": "Konto, kotrež sy za instalaciju podał, nima dosć woprawnjenjow, zo by konto wutworiło.\nKonto, kotrež tu podawaće, dyrbi hižo eksistować.", "config-mysql-engine": "Składowanska mašina:", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", "config-site-name": "Mjeno wikija:", "config-site-name-help": "To zjewi so w titulowej lejstwje wobhladaka kaž tež na wšelakich druhich městnach.", "config-site-name-blank": "Zapodaj sydłowe mjeno.", diff --git a/includes/installer/i18n/hu.json b/includes/installer/i18n/hu.json index a2a4e3bd05..fed6854585 100644 --- a/includes/installer/i18n/hu.json +++ b/includes/installer/i18n/hu.json @@ -53,7 +53,7 @@ "config-help-restart": "Szeretnéd törölni az eddig megadott összes adatot és újraindítani a telepítési folyamatot?", "config-restart": "Igen, újraindítás", "config-welcome": "=== A környezet ellenőrzése ===\nNéhány alapvető ellenőrzés hajtódik végre, hogy kiderüljön, hogy ez a környezet alkalmas-e a MediaWiki telepítésére.\nHa segítséget kérsz a telepítéssel kapcsolatban, add meg ezen ellenőrzések eredményét.", - "config-copyright": "=== Licenc és feltételek ===\n\n$1\n\nEz a program szabad szoftver; terjeszthető, illetve módosítható a Free Software Foundation által kiadott GNU General Public License dokumentumában leírtak; akár a licenc 2-es, akár (tetszőleges) későbbi változata szerint.\n\nEz a program abban a reményben kerül közreadásra, hogy hasznos lesz, de minden egyéb garancia nélkül, az eladhatóságra vagy valamely célra való alkalmazhatóságra való származtatott garanciát is beleértve. További részleteket a GNU General Public License tartalmaz.\n\nA felhasználónak a programmal együtt meg kell kapnia a GNU General Public License egy példányát; ha mégsem kapta meg, akkor írjon a Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. címre, vagy [https://www.gnu.org/copyleft/gpl.html tekintse meg online].", + "config-welcome-section-copyright": "=== Licenc és feltételek ===\n\n$1\n\nEz a program szabad szoftver; terjeszthető, illetve módosítható a Free Software Foundation által kiadott GNU General Public License dokumentumában leírtak; akár a licenc 2-es, akár (tetszőleges) későbbi változata szerint.\n\nEz a program abban a reményben kerül közreadásra, hogy hasznos lesz, de minden egyéb garancia nélkül, az eladhatóságra vagy valamely célra való alkalmazhatóságra való származtatott garanciát is beleértve. További részleteket a GNU General Public License tartalmaz.\n\nA felhasználónak a programmal együtt meg kell kapnia a [$2 GNU General Public License egy példányát]; ha mégsem kapta meg, akkor írjon a Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. címre, vagy [https://www.gnu.org/copyleft/gpl.html tekintse meg online].", "config-sidebar": "* [https://www.mediawiki.org A MediaWiki honlapja]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Felhasználói kézikönyv]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Útmutató adminisztrátoroknak]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ GyIK]\n----\n* Ismertető\n* Kiadási megjegyzések\n* Másolás\n* Frissítés", "config-env-good": "A környezet ellenőrzése befejeződött.\nA MediaWiki telepíthető.", "config-env-bad": "A környezet ellenőrzése befejeződött.\nA MediaWiki nem telepíthető.", @@ -163,8 +163,6 @@ "config-db-web-no-create-privs": "A telepítéshez megadott fiók nem rendelkezik megfelelő jogosultságokkal új felhasználó létrehozásához.\nAz itt megadott fióknak léteznie kell.", "config-mysql-engine": "Tárolómotor:", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "'''Figyelmeztetés''': A MyISAM tárolómotort választottad, ami nem ajánlott a MediaWiki használatánál, mert:\n* nagyon rosszul kezeli a párhuzamos lekéréseket a táblák zárolása miatt\n* sokkal nagyobb az esélye az adatkorrupció kialakulásának\n* a MediaWiki kódbázisa nem mindig úgy kezeli a MyISAM-ot, ahogyan kellene\n\nHa a feltelepített MySQL támogatja az InnoDB-t, erősen ajánlott, hogy inkább azt válaszd.\nHa nem, akkor lehet, hogy itt az ideje a frissítésnek.", "config-mysql-engine-help": "A legtöbb esetben az '''InnoDB''' a legjobb választás, mivel megfelelően támogatja a párhuzamosságot.\n\nA '''MyISAM''' gyorsabb megoldás lehet egyfelhasználós vagy csak olvasható környezetekben, azonban a MyISAM-adatbázisok sokkal gyakrabban sérülnek meg, mint az InnoDB-adatbázisok.", "config-mssql-auth": "Hitelesítés típusa:", "config-mssql-sqlauth": "SQL Server hitelesítés", diff --git a/includes/installer/i18n/ia.json b/includes/installer/i18n/ia.json index 67f769f990..9e08055514 100644 --- a/includes/installer/i18n/ia.json +++ b/includes/installer/i18n/ia.json @@ -45,14 +45,14 @@ "config-help-restart": "Vole tu rader tote le datos salveguardate que tu ha entrate e reinitiar le processo de installation?", "config-restart": "Si, reinitia lo", "config-welcome": "=== Verificationes del ambiente ===\nVerificationes de base essera ora exequite pro determinar si iste ambiente es apte pro le installation de MediaWiki.\nNon oblida de includer iste information si tu cerca adjuta pro completar le installation.", - "config-copyright": "=== Copyright and Terms ===\n\n$1\n\nIste programma es software libere; vos pote redistribuer lo e/o modificar lo sub le conditiones del Licentia Public General de GNU publicate per le Free Software Foundation; version 2 del Licentia, o (a vostre option) qualcunque version posterior.\n\nIste programma es distribuite in le sperantia que illo sia utile, ma '''sin garantia''', sin mesmo le implicite garantia de '''commercialisation''' o '''aptitude pro un proposito particular'''.\nVide le Licentia Public General de GNU pro plus detalios.\n\nVos deberea haber recipite un exemplar del Licentia Public General de GNU con iste programma; si non, scribe al Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, o [https://www.gnu.org/copyleft/gpl.html lege lo in linea].", + "config-welcome-section-copyright": "=== Copyright and Terms ===\n\n$1\n\nIste programma es software libere; vos pote redistribuer lo e/o modificar lo sub le conditiones del Licentia Public General de GNU publicate per le Free Software Foundation; version 2 del Licentia, o (a vostre option) qualcunque version posterior.\n\nIste programma es distribuite in le sperantia que illo sia utile, ma '''sin garantia''', sin mesmo le implicite garantia de '''commercialisation''' o '''aptitude pro un proposito particular'''.\nVide le Licentia Public General de GNU pro plus detalios.\n\nVos deberea haber recipite [$2 un exemplar del Licentia Public General de GNU] con iste programma; si non, scribe al Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, o [https://www.gnu.org/copyleft/gpl.html lege lo in linea].", "config-sidebar": "* [https://www.mediawiki.org Pagina principal de MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Guida pro usatores]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Guida pro administratores]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]\n----\n* Lege me\n* Notas de iste version\n* Conditiones de copia\n* Actualisation", "config-env-good": "Le ambiente ha essite verificate.\nTu pote installar MediaWiki.", "config-env-bad": "Le ambiente ha essite verificate.\nTu non pote installar MediaWiki.", "config-env-php": "PHP $1 es installate.", "config-env-hhvm": "HHVM $1 es installate.", - "config-unicode-using-intl": "Le [https://pecl.php.net/intl extension PECL intl] es usate pro le normalisation Unicode.", - "config-unicode-pure-php-warning": "'''Aviso''': Le [https://pecl.php.net/intl extension PECL intl] non es disponibile pro exequer le normalisation Unicode; le systema recurre al implementation lente in PHP pur.\nSi tu sito ha un alte volumine de traffico, tu deberea informar te un poco super le [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalisation Unicode].", + "config-unicode-using-intl": "Le [https://php.net/manual/en/book.intl.php extension PHP intl] es usate pro le normalisation Unicode.", + "config-unicode-pure-php-warning": "'''Aviso''': Le [https://php.net/manual/en/book.intl.php extension PHP intl] non es disponibile pro exequer le normalisation Unicode; le systema recurre al implementation lente in PHP pur.\nSi tu sito ha un alte volumine de traffico, tu deberea informar te super le [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalisation Unicode].", "config-unicode-update-warning": "'''Aviso''': Le version installate del bibliotheca inveloppante pro normalisation Unicode usa un version ancian del bibliotheca del [http://site.icu-project.org/ projecto ICU].\nTu deberea [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations actualisar lo] si le uso de Unicode importa a te.", "config-no-db": "Non poteva trovar un driver appropriate pro le base de datos! Es necessari installar un driver de base de datos pro PHP.\nLe sequente {{PLURAL:$2|typo|typos}} de base de datos es supportate: $1.\n\nSi tu compilava PHP tu mesme, reconfigura lo con un cliente de base de datos activate, per exemplo, usante ./configure --with-mysqli.\nSi tu installava PHP ex un pacchetto Debian o Ubuntu, tu debe etiam installar, per exemplo, le modulo php-mysql.", "config-outdated-sqlite": "Attention: tu ha SQLite $2, que es inferior al minime version requirite, $1. SQLite essera indisponibile.", @@ -163,9 +163,6 @@ "config-db-web-no-create-privs": "Le conto que tu specificava pro installation non ha sufficiente privilegios pro crear un conto.\nLe conto que tu specifica hic debe jam exister.", "config-mysql-engine": "Motor de immagazinage:", "config-mysql-innodb": "InnoDB (recommendate)", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "* '''Attention:''' Tu ha seligite MyISAM como motor de immagazinage pro MySQL, lo que non es recommendate pro uso con MediaWiki, perque:\n* illo a pena supporta le processamento simultanee a causa del blocada le tabulas\n* illo es plus susceptibile al corruption que altere motores\n* le base de codice de MediaWiki non sempre manea MyISAM como illo deberea\n\nSi tu installation de MySQL supporta InnoDB, es multo recommendate que tu selige iste in su loco.\nSi tu installation de MySQL non supporta InnoDB, forsan isto es un bon occasion pro actualisar lo.", - "config-mysql-only-myisam-dep": "'''Attention:''' MyISAM es le unic motor de immagazinage disponibile pro MySQL in iste machina, ma isto non es recommendate pro le uso con MediaWiki, perque:\n* a pena supporto le accesso simultanee a causa del blocage de tabellas\n* es plus propense a corrumper se que altere motores\n* le codice base de MediaWiki non sempre gere MyISAM como deberea\n\nTu installation de MySQL non supporta InnoDB; forsan il es tempore de actualisar lo.", "config-mysql-engine-help": "'''InnoDB''' es quasi sempre le melior option, post que illo ha bon supporto pro simultaneitate.\n\n'''MyISAM''' pote esser plus rapide in installationes a usator singule o a lectura solmente.\nLe bases de datos MyISAM tende a esser corrumpite plus frequentemente que le base de datos InnoDB.", "config-mssql-auth": "Typo de authentication:", "config-mssql-install-auth": "Selige le typo de authentication a usar pro connecter al base de datos durante le processo de installation.\nSi tu selige \"{{int:config-mssql-windowsauth}}\", le credentiales del usator que executa le servitor web essera usate.", diff --git a/includes/installer/i18n/id.json b/includes/installer/i18n/id.json index 007163419d..7fd77ee29f 100644 --- a/includes/installer/i18n/id.json +++ b/includes/installer/i18n/id.json @@ -55,7 +55,7 @@ "config-help-restart": "Apakah Anda ingin menghapus semua data tersimpan yang telah Anda masukkan dan mengulang proses instalasi?", "config-restart": "Ya, nyalakan ulang", "config-welcome": "=== Pengecekan lingkungan ===\nPengecekan dasar kini akan dilakukan untuk melihat apakah lingkungan ini memadai untuk instalasi MediaWiki.\nIngatlah untuk menyertakan informasi ini jika Anda mencari bantuan tentang cara menyelesaikan instalasi.", - "config-copyright": "=== Hak cipta dan persyaratan ===\n\n$1\n\nProgram ini adalah perangkat lunak bebas; Anda dapat mendistribusikan dan/atau memodifikasi di bawah persyaratan GNU General Public License seperti yang diterbitkan oleh Free Software Foundation; baik versi 2 lisensi, atau (sesuai pilihan Anda) versi yang lebih baru.\n\nProgram ini didistribusikan dengan harapan bahwa itu akan berguna, tetapi tanpa jaminan apa pun; bahkan tanpa jaminan tersirat untuk dapat diperjualbelikan atau sesuai untuk tujuan tertentu.\nLihat GNU General Public License untuk lebih jelasnya.\n\nAnda seharusnya telah menerima salinan dari GNU General Public License bersama dengan program ini; jika tidak, kirimkan surat untuk Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, atau [https://www.gnu.org/copyleft/gpl.html baca versi daring].", + "config-welcome-section-copyright": "=== Hak cipta dan persyaratan ===\n\n$1\n\nProgram ini adalah perangkat lunak bebas; Anda dapat mendistribusikan dan/atau memodifikasi di bawah persyaratan GNU General Public License seperti yang diterbitkan oleh Free Software Foundation; baik versi 2 lisensi, atau (sesuai pilihan Anda) versi yang lebih baru.\n\nProgram ini didistribusikan dengan harapan bahwa itu akan berguna, tetapi tanpa jaminan apa pun; bahkan tanpa jaminan tersirat untuk dapat diperjualbelikan atau sesuai untuk tujuan tertentu.\nLihat GNU General Public License untuk lebih jelasnya.\n\nAnda seharusnya telah menerima [$2 salinan dari GNU General Public License] bersama dengan program ini; jika tidak, kirimkan surat untuk Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, atau [https://www.gnu.org/copyleft/gpl.html baca versi daring].", "config-sidebar": "* [https://www.mediawiki.org/wiki/MediaWiki/id Situs MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents/id Pedoman Pengguna]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents/id Pedoman Administrator]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/id FAQ]\n----\n* Read me\n* Release notes\n* Copying\n* Upgrading", "config-env-good": "Kondisi telah diperiksa.\nAnda dapat menginstal MediaWiki.", "config-env-bad": "Kondisi telah diperiksa.\nAnda tidak dapat menginstal MediaWiki.", @@ -171,9 +171,6 @@ "config-db-web-no-create-privs": "Akun Anda berikan untuk instalasi tidak memiliki hak yang cukup untuk membuat akun.\nAkun yang Anda berikan harus sudah ada.", "config-mysql-engine": "Mesin penyimpanan:", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "Peringatan: Anda telah memilih MyISAM sebagai mesin penyimpanan MySQL, yang tidak dianjurkan untuk digunakan dengan MediaWiki, karena:\n * nyaris tidak mendukung operasi bersamaan karena penguncian tabel\n * lebih rentan terhadap korupsi daripada mesin lain\n * basis kode MediaWiki tidak selalu menangani MyISAM sebagaimana mestinya\n\nJika instalasi MySQL Anda mendukung InnoDB, sangat disarankan bagi Anda memilih itu.\nJika instalasi MySQL tidak mendukung InnoDB, mungkin sudah waktunya untuk pemutakhiran.", - "config-mysql-only-myisam-dep": "Peringatan: MyISAM adalah satu-satunya mesin penyimpanan yang tersedia untuk MySQL pada mesin ini, dan hal ini tidak dianjurkan untuk digunakan dengan MediaWiki, karena:\n* hampir tidak mendukung konkurensi karena penguncian tabel\n* basis kode MediaWiki tidak selalu menangani MyISAM sebagaimana mestinya\n\nInstalasi MySQL Anda tidak mendukung InnoDB, mungkin sudah waktunya untuk peningkatan.", "config-mysql-engine-help": "'''InnoDB''' hampir selalu merupakan pilihan terbaik karena memiliki dukungan konkurensi yang baik.\n\n'''MyISAM''' mungkin lebih cepat dalam instalasi pengguna-tunggal atau hanya-baca.\nBasis data MyISAM cenderung lebih sering rusak daripada basis data InnoDB.", "config-mssql-auth": "Jenis otentikasi:", "config-mssql-install-auth": "Pilih jenis otentikasi yang akan digunakan untuk menyambung ke database selama proses instalasi.\nJika Anda memilih \"{{int:config-mssql-windowsauth}}\", kredensial dari pengguna apapun pada server web yang berjalan akan digunakan.", diff --git a/includes/installer/i18n/io.json b/includes/installer/i18n/io.json index 4e9781b5dd..fac0ad7ebc 100644 --- a/includes/installer/i18n/io.json +++ b/includes/installer/i18n/io.json @@ -10,6 +10,8 @@ "config-title": "Instalo di MediaWiki $1", "config-information": "Informo", "config-localsettings-upgrade": "L'arkivo LocalSettings.php trovesis.\nPor plubonigar l'instaluro, voluntez informar la valoro dil $wgUpgradeKey en l'infra buxo.\nVu trovos ol en LocalSettings.php.", + "config-session-error": "Eroro dum komenco di seciono: $1", + "config-session-expired": "Vua sesiono probable finis.\nSesioni programesis por durar $1\nVu povas augmentar to per modifiko di session.gc_maxlifetime en php.ini.\nRikomencez l'instalo-procedo.", "config-your-language": "Vua idiomo:", "config-your-language-help": "Selektez l'idiomo por uzar dum l'instalo-procedo.", "config-wiki-language": "Wiki linguo:", @@ -33,16 +35,43 @@ "config-page-existingwiki": "Existanta wiki", "config-help-restart": "Ka vu deziras efacar omna dati qui vu sparis, e rikomencar la procedi pri instalo?", "config-restart": "Yes, rikomencez ol", - "config-copyright": "=== Autoroyuro e termini ===\n\n$1\n\nCa informatikoprogramo esas libera; vu povas ridistributar ol e/o modifikar ol segun la termini de la Generala Licenco Publika GNU, quale stipulata da Free Software Foundation; segun lua versiono 2 o sequanta.\n\nNi expektas ke ca programo esas utila, kande ni distributas ol. Tamen, ni ne povas grantar ke ol fakte esos utila. Ni anke ne povas afirmar ke ol esos vendebla o ke ol esos utila por specifika intenco.\nLektez la Generala Licenco Publika GNU por plusa detali.\n\nKune ica programo vu certe recevis kopiuro di la Generala Licenco Publika GNU. Se vu ne recevis ol, voluntez skribar a la fonduro Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, o [https://www.gnu.org/copyleft/gpl.html lektez ol che l'interreto].", + "config-welcome-section-copyright": "=== Autoroyuro e termini ===\n\n$1\n\nCa informatikoprogramo esas libera; vu povas ridistributar ol e/o modifikar ol segun la termini de la Generala Licenco Publika GNU, quale stipulata da Free Software Foundation; segun lua versiono 2 o sequanta.\n\nNi expektas ke ca programo esas utila, kande ni distributas ol. Tamen, ni ne povas grantar ke ol fakte esos utila. Ni anke ne povas afirmar ke ol esos vendebla o ke ol esos utila por specifika intenco.\nLektez la Generala Licenco Publika GNU por plusa detali.\n\nKune ica programo vu certe recevis [$2 kopiuro di la Generala Licenco Publika GNU]. Se vu ne recevis ol, voluntez skribar a la fonduro Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, o [https://www.gnu.org/copyleft/gpl.html lektez ol che l'interreto].", "config-env-good": "Omno verifikesis.\nVu povas intalar MediaWiki.", "config-env-bad": "Omno verifikesis.\nVu NE POVAS intalar MediaWiki.", "config-env-php": "PHP $1 instalesis.", "config-env-hhvm": "HHVM $1 instalesis.", + "config-unicode-pure-php-warning": "Atencez: La [https://php.net/manual/en/book.intl.php prolonguro PHP intl] ne esas disponebla por traktar skribo-normaligo \"Unicode\". Vice, uzesas la plu lenta laborado en pura PHP.\nSe vu administras pagini multe vizitata, vu mustas lektar la [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations skribo-normaligo Unicode].", + "config-memory-raised": "Parametro memory_limit esas $1, modifikata a $2.", + "config-memory-bad": "Atences: la limito por PHP memory_limit esas $1.\nTo probable esas nesuficanta.\nL'instalo-procedo povas faliar!", "config-apc": "[https://www.php.net/apc APC] instalesis", "config-apcu": "[https://www.php.net/apcu APCu] instalesis", + "config-wincache": "[https://www.iis.net/downloads/microsoft/wincache-extension WinCache] instalesis", + "config-using-uri": "Ret-adreso (URL) dil servero \"$1$2\".", + "config-db-wiki-settings": "Identifikez ca wiki", + "config-db-name": "Nomo dil datumaro (sen strekteti):", + "config-db-install-account": "Konto dil uzero por instalo", + "config-db-username": "Uzero-nomo dil datumaro:", + "config-db-password": "Pasovorto dil datumaro:", + "config-type-mssql": "Microsoft SQL Server", + "config-header-oracle": "Ajusti por Oracle-sistemo:", + "config-header-mssql": "Ajusti por Microsoft SQL Server", + "config-invalid-db-type": "Nevalida tipo di datumaro.", + "config-ns-generic": "Projeto", + "config-ns-site-name": "Sama kam la wiki-nomo: $1", + "config-ns-other": "Altra (definez precise)", + "config-ns-other-default": "MyWiki", + "config-admin-name": "Vua uzero-nomo:", + "config-admin-password": "Pasovorto:", + "config-admin-password-confirm": "Riskribez la pasovorto:", + "config-admin-email": "E-postal adreso:", + "config-profile-wiki": "Aperta wiki", + "config-profile-no-anon": "Bezonas krear konto", + "config-profile-fishbowl": "Nur permisata redakteri", "config-profile-private": "Privata wiki", + "config-profile-help": "Wikis work best when you let as many people edit them as possible.\nIn MediaWiki, it is easy to review the recent changes, and to revert any damage that is done by naive or malicious users.\n\nHowever, many have found MediaWiki to be useful in a wide variety of roles, and sometimes it is not easy to convince everyone of the benefits of the wiki way.\nSo you have the choice.\n\nThe {{int:config-profile-wiki}} model allows anyone to edit, without even logging in.\nA wiki with {{int:config-profile-no-anon}} provides extra accountability, but may deter casual contributors.\n\nThe {{int:config-profile-fishbowl}} scenario allows approved users to edit, but the public can view the pages, including history.\nA {{int:config-profile-private}} only allows approved users to view pages, with the same group allowed to edit.\n\nMore complex user rights configurations are available after installation, see the [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights relevant manual entry].", "config-license": "Autoroyuro e permiso:", "config-license-cc-0": "Creative Commons Zero (Publika domeno)", + "config-license-pd": "Publika domeno", "config-install-step-done": "Facita", "config-install-step-failed": "faliis", "config-install-extensions": "Komplementi inkluzita", diff --git a/includes/installer/i18n/is.json b/includes/installer/i18n/is.json index 3af2fc030d..59c23175a4 100644 --- a/includes/installer/i18n/is.json +++ b/includes/installer/i18n/is.json @@ -31,7 +31,7 @@ "config-page-upgradedoc": "Uppfærsla", "config-page-existingwiki": "Fyrirliggjandi wiki", "config-restart": "Já, endurræsa", - "config-copyright": "=== Höfundarréttur og skilmálar ===\n\n$1\n\nÞetta er frjáls hugbúnaður; þú mátt dreifa honum og/eða breyta samkvæmt skilmálum í almenna GNU GPL notkunarleyfinu eins og það er gefið út af Frjálsu hugbúnaðarstofnuninni; annaðhvort útgáfu 2 af GPL-leyfinu, eða (ef þér sýnist svo) einhverri nýrri útgáfu leyfisins.\n\nHugbúnaði þessum er dreift í þeirri von að hann geti verið gagnlegur, en ÁN ALLRAR ÁBYRGÐAR; einnig án þeirrar ábyrgðar sem gefin er í skyn með SELJANLEIKA eða EIGINLEIKUM TIL TILTEKINNA NOTA. Sjá almenna GNU GPL notkunarleyfið fyrir nánari upplýsingar.\n\nÞað ætti að hafa fylgt afrit af almenna GNU GPL notkunarleyfinu með forritinu; ef ekki skrifið þá Fjálsu hugbúnarstofnuninni: Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, eða [https://www.gnu.org/copyleft/gpl.html lestu það á netinu].", + "config-welcome-section-copyright": "=== Höfundarréttur og skilmálar ===\n\n$1\n\nÞetta er frjáls hugbúnaður; þú mátt dreifa honum og/eða breyta samkvæmt skilmálum í almenna GNU GPL notkunarleyfinu eins og það er gefið út af Frjálsu hugbúnaðarstofnuninni; annaðhvort útgáfu 2 af GPL-leyfinu, eða (ef þér sýnist svo) einhverri nýrri útgáfu leyfisins.\n\nHugbúnaði þessum er dreift í þeirri von að hann geti verið gagnlegur, en ÁN ALLRAR ÁBYRGÐAR; einnig án þeirrar ábyrgðar sem gefin er í skyn með SELJANLEIKA eða EIGINLEIKUM TIL TILTEKINNA NOTA. Sjá almenna GNU GPL notkunarleyfið fyrir nánari upplýsingar.\n\nÞað ætti að hafa fylgt afrit af almenna [$2 GNU GPL notkunarleyfinu] með forritinu; ef ekki skrifið þá Fjálsu hugbúnarstofnuninni: Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, eða [https://www.gnu.org/copyleft/gpl.html lestu það á netinu].", "config-env-php": "PHP $1 er uppsett.", "config-env-hhvm": "HHVM $1 er uppsett.", "config-apc": "[https://www.php.net/apc APC] er uppsett", @@ -62,7 +62,6 @@ "config-db-web-account": "Gagnagrunnsreikningur fyrir vefaðgang", "config-mysql-engine": "Gagnagrunnshýsing:", "config-mysql-innodb": "InnoDB (mælt með)", - "config-mysql-myisam": "MyISAM", "config-mssql-auth": "Tegund auðkenningar:", "config-mssql-sqlauth": "SQL Server auðkenning", "config-mssql-windowsauth": "Windows auðkenning", diff --git a/includes/installer/i18n/it.json b/includes/installer/i18n/it.json index c87897951a..165a0570c5 100644 --- a/includes/installer/i18n/it.json +++ b/includes/installer/i18n/it.json @@ -22,7 +22,8 @@ "Tosky", "Selven", "Sarah Bernabei", - "ArTrix" + "ArTrix", + "Annibale covini gerolamo" ] }, "config-desc": "Programma di installazione per MediaWiki", @@ -62,14 +63,18 @@ "config-help-restart": "Vuoi cancellare tutti i dati salvati che hai inserito e riavviare il processo di installazione?", "config-restart": "Sì, riavvia", "config-welcome": "=== Controllo dell'ambiente ===\nSaranno eseguiti controlli di base per vedere se questo ambiente è adatto per l'installazione di MediaWiki.\nRicordati di includere queste informazioni se chiedi assistenza su come completare l'installazione.", - "config-copyright": "=== Copyright e termini ===\n\n$1\n\nQuesto programma è un software libero; puoi redistribuirlo e/o modificarlo secondo i termini della GNU General Public License, come pubblicata dalla Free Software Foundation; o la versione 2 della Licenza o (a propria scelta) qualunque versione successiva.\n\nQuesto programma è distribuito nella speranza che sia utile, ma SENZA ALCUNA GARANZIA; senza neppure la garanzia implicita di NEGOZIABILITÀ o di APPLICABILITÀ PER UN PARTICOLARE SCOPO.\nSi veda la GNU General Public License per maggiori dettagli.\n\nQuesto programma deve essere distribuito assieme ad una copia della GNU General Public License; in caso contrario, se ne può ottenere una scrivendo alla Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA oppure [https://www.gnu.org/copyleft/gpl.html leggerla in rete].", - "config-sidebar": "* [https://www.mediawiki.org Pagina principale MediaWiki]\n* [https://www.mediawiki.org/wiki/Aiuto:Guida ai contenuti per utenti]\n* [https://www.mediawiki.org/wiki/Manuale:Guida ai contenuti per admin]\n* [https://www.mediawiki.org/wiki/Manuale:FAQ FAQ]\n----\n* Leggimi\n* Note di versione\n* Copie\n* Aggiornamenti", + "config-welcome-section-copyright": "=== Copyright e termini ===\n\n$1\n\nQuesto programma è un software libero; puoi redistribuirlo e/o modificarlo secondo i termini della GNU General Public License, come pubblicata dalla Free Software Foundation; o la versione 2 della Licenza o (a propria scelta) qualunque versione successiva.\n\nQuesto programma è distribuito nella speranza che sia utile, ma SENZA ALCUNA GARANZIA; senza neppure la garanzia implicita di NEGOZIABILITÀ o di APPLICABILITÀ PER UN PARTICOLARE SCOPO.\nSi veda la GNU General Public License per maggiori dettagli.\n\nQuesto programma deve essere distribuito assieme ad [$2 una copia della GNU General Public License]; in caso contrario, se ne può ottenere una scrivendo alla Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA oppure [https://www.gnu.org/copyleft/gpl.html leggerla in rete].", + "config-sidebar": "* [https://www.mediawiki.org Pagina principale MediaWiki]\n* [https://www.mediawiki.org/Special:MyLanguage/Help:Contents Guida ai contenuti per utenti]\n* [https://www.mediawiki.org/Special:MyLanguage/Manual:Contents Guida ai contenuti per admin]\n* [https://www.mediawiki.org/Special:MyLanguage/Manual:FAQ FAQ]", + "config-sidebar-readme": "Leggimi", + "config-sidebar-relnotes": "Note di versione", + "config-sidebar-license": "Licenza", + "config-sidebar-upgrade": "Aggiornamento", "config-env-good": "L'ambiente è stato controllato.\nÈ possibile installare MediaWiki.", "config-env-bad": "L'ambiente è stato controllato.\nNon è possibile installare MediaWiki.", "config-env-php": "PHP $1 è installato.", "config-env-hhvm": "HHVM $1 è installato.", - "config-unicode-using-intl": "Usa [https://pecl.php.net/intl l'estensione PECL intl] per la normalizzazione Unicode.", - "config-unicode-pure-php-warning": "'''Attenzione:''' [https://pecl.php.net/intl l'estensione PECL intl] non è disponibile per gestire la normalizzazione Unicode, quindi si torna alla lenta implementazione in PHP puro.\nSe esegui un sito ad alto traffico, dovresti leggere alcune considerazioni sulla [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalizzazione Unicode].", + "config-unicode-using-intl": "Usa [https://php.net/manual/en/book.intl.php l'estensione PHP intl] per la normalizzazione Unicode.", + "config-unicode-pure-php-warning": "Attenzione: [https://php.net/manual/en/book.intl.php l'estensione PHP intl] non è disponibile per gestire la normalizzazione Unicode, quindi si torna alla lenta implementazione in PHP puro.\nSe esegui un sito ad alto traffico, dovresti leggere [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalizzazione Unicode].", "config-unicode-update-warning": "'''Attenzione:''' la versione installata del gestore per la normalizzazione Unicode usa una vecchia versione della libreria [http://site.icu-project.org/ del progetto ICU].\nDovresti [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations aggiornare] se vuoi usare l'Unicode.", "config-no-db": "Impossibile trovare un driver adatto per il database! È necessario installare un driver per PHP.\n{{PLURAL:$2|Il seguente formato di database è supportato|I seguenti formati di database sono supportati}}: $1.\n\nSe compili PHP autonomamente, riconfiguralo attivando un client database, per esempio utilizzando ./configure --with-mysqli.\nQualora avessi installato PHP per mezzo di un pacchetto Debian o Ubuntu, allora devi installare anche il pacchetto php-mysql.", "config-outdated-sqlite": "Attenzione: è presente SQLite $2 mentre è richiesta la versione $1, SQLite non sarà disponibile.", @@ -177,9 +182,6 @@ "config-db-web-no-create-privs": "L'account usato per l'installazione non dispone dei privilegi necessari per creare un altro account.\nL'account indicato qui deve già esistere.", "config-mysql-engine": "Storage engine:", "config-mysql-innodb": "InnoDB (consigliato)", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "Attenzione: hai selezionato MyISAM come motore di archiviazione per MySQL, che non è raccomandato per l'uso con MediaWiki, perché:\n* supporta debolmente la concorrenza per il blocco della tabella\n* è più incline alla corruzione di altri motori\n* il codice di base MediaWiki non gestisce sempre MyISAM come dovrebbe\n\nSe la tua installazione MySQL supporta InnoDB, è altamente raccomandato che lo si scelga al suo posto.\nSe la tua installazione MySQL non supporta InnoDB, forse è il momento per un aggiornamento.", - "config-mysql-only-myisam-dep": "Attenzione: MyISAM è l'unico motore di archiviazione disponibile per MySQL su questa macchina, e questo non è consigliato per l'uso con MediaWiki, perché:\n* supporta debolmente la concorrenza per il blocco della tabella\n* è più incline alla corruzione di altri motori\n* il codice di base MediaWiki non gestisce sempre MyISAM come dovrebbe\n\nSe la tua installazione MySQL non supporta InnoDB, forse è il momento per un aggiornamento.", "config-mysql-engine-help": "InnoDB è quasi sempre l'opzione migliore, in quanto ha un buon supporto della concorrenza.\n\nMyISAM potrebbe essere più veloce nelle installazioni monoutente o in sola lettura.\nI database MyISAM tendono a danneggiarsi più spesso dei database InnoDB.", "config-mssql-auth": "Tipo di autenticazione:", "config-mssql-install-auth": "Seleziona il tipo di autenticazione che verrà utilizzato per connettersi al database durante il processo di installazione.\nSe si seleziona \"{{int:config-mssql-windowsauth}}\", saranno utilizzate le credenziali dell'utente con cui viene eseguito il server web, qualunque esso sia.", diff --git a/includes/installer/i18n/ja.json b/includes/installer/i18n/ja.json index f8318f0c8d..9516433e73 100644 --- a/includes/installer/i18n/ja.json +++ b/includes/installer/i18n/ja.json @@ -65,17 +65,17 @@ "config-help-restart": "入力した保存データをすべて消去して、インストール作業を再起動しますか?", "config-restart": "はい、再起動します", "config-welcome": "=== 環境の確認 ===\n基本的な確認では、現在の環境が MediaWiki のインストールに適しているかを確認します。\nインストール方法について助けが必要になった場合は、必ずこの確認結果を添えてください。", - "config-copyright": "=== 著作権および規約 ===\n$1\n\nこの作品はフリーソフトウェアです。あなたは、フリーソフトウェア財団の発行する GNU 一般公衆利用許諾書 (GNU General Public License) (バージョン 2、またはそれ以降のライセンス) の規約に基づき、このライブラリを再配布および改変できます。\n\nこの作品は、有用であることを期待して配布されていますが、商用または特定の目的に適するかどうかも含めて、暗黙的にも、一切保証されません。\n詳しくは、 GNU 一般公衆利用許諾書をご覧ください。\n\nあなたはこのプログラムと共に、GNU 一般公衆利用許諾契約書の複製を受け取ったはずです。受け取っていない場合は、フリーソフトウェア財団 (宛先は the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA) まで請求するか、または[https://www.gnu.org/copyleft/gpl.html オンラインでお読みください]。", + "config-welcome-section-copyright": "=== 著作権および規約 ===\n$1\n\nこの作品はフリーソフトウェアです。あなたは、フリーソフトウェア財団の発行する GNU 一般公衆利用許諾書 (GNU General Public License) (バージョン 2、またはそれ以降のライセンス) の規約に基づき、このライブラリを再配布および改変できます。\n\nこの作品は、有用であることを期待して配布されていますが、商用または特定の目的に適するかどうかも含めて、暗黙的にも、一切保証されません。\n詳しくは、 GNU 一般公衆利用許諾書をご覧ください。\n\nあなたはこのプログラムと共に、[$2 GNU 一般公衆利用許諾契約書の複製]を受け取ったはずです。受け取っていない場合は、フリーソフトウェア財団 (宛先は the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA) まで請求するか、または[https://www.gnu.org/copyleft/gpl.html オンラインでお読みください]。", "config-sidebar": "* [https://www.mediawiki.org MediaWikiのホーム]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents 利用者向け案内]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents 管理者向け案内]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]\n----\n* お読みください\n* リリースノート\n* コピー\n* アップグレード", "config-env-good": "環境を確認しました。\nMediaWiki をインストールできます。", "config-env-bad": "環境を確認しました。\nMediaWiki のインストールはできません。", "config-env-php": "PHP $1がインストールされています。", "config-env-hhvm": "HHVM $1 がインストールされています。", - "config-unicode-using-intl": "Unicode正規化に[https://pecl.php.net/intl intl PECL 拡張機能]を使用。", - "config-unicode-pure-php-warning": "警告: Unicode 正規化の処理に [https://pecl.php.net/intl intl PECL 拡張機能]を利用できないため、処理が遅いピュア PHP の実装を代わりに使用しています。\n高トラフィックのサイトを運営する場合は、[https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode 正規化]をお読みください。", + "config-unicode-using-intl": "Unicode正規化に[https://php.net/manual/en/book.intl.php PHP intl \n 拡張機能]を使用。", + "config-unicode-pure-php-warning": "警告: Unicode 正規化の処理に[https://php.net/manual/en/book.intl.php PHP intl 拡張機能]を利用できないため、処理が遅いピュア PHP の実装を代わりに使用しています。\n高トラフィックのサイトを運営する場合、[https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode 正規化]は必ず読むよう推奨されます。", "config-unicode-update-warning": "警告: インストールされているバージョンの Unicode 正規化ラッパーは、[http://site.icu-project.org/ ICU プロジェクト]のライブラリの古いバージョンを使用しています。\nUnicode を少しでも利用する可能性がある場合は、[https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations アップグレード]してください。", "config-no-db": "適切なデータベース ドライバーが見つかりませんでした! PHP にデータベース ドライバーをインストールする必要があります。\n以下の種類のデータベース{{PLURAL:$2|のタイプ}}に対応しています: $1\n\nPHP を自分でコンパイルした場合は、例えば ./configure --with-mysqli を実行して、データベース クライアントを使用できるように再設定してください。\nDebian または Ubuntu のパッケージから PHP をインストールした場合は、モジュール (例: php-mysql) もインストールする必要があります。", - "config-outdated-sqlite": "警告: あなたは SQLite $1 を使用していますが、最低限必要なバージョン $2 より古いバージョンです。SQLite は利用できません。", + "config-outdated-sqlite": "警告: ご利用の SQLite $2 は容認されている最古の版 $1 よりも古い版です。SQLite が対応しません。", "config-no-fts3": "警告: SQLite は [//sqlite.org/fts3.html FTS3] モジュールなしでコンパイルされており、このバックエンドでは検索機能は利用できなくなります。", "config-pcre-old": "致命的エラー: PCRE $1 以降が必要です。\nご使用中の PHP のバイナリは PCRE $2 とリンクされています。\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE 詳細情報]", "config-pcre-no-utf8": "致命的エラー: PHP の PCRE が PCRE_UTF8 対応なしでコンパイルされているようです。\nMediaWiki を正しく動作させるには、UTF-8 対応が必要です。", @@ -154,7 +154,7 @@ "config-invalid-db-server-oracle": "「$1」は無効なデータベース TNS です。\n「TNS 名」「Easy Connect」文字列のいずれかを使用してください ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Oracle ネーミング メソッド])。", "config-invalid-db-name": "「$1」は無効なデータベース名です。\n半角の英数字 (a-z、A-Z、0-9)、アンダースコア (_)、ハイフン (-) のみを使用してください。", "config-invalid-db-prefix": "「$1」は無効なデータベース接頭辞です。\n半角の英数字 (a-z、A-Z、0-9)、アンダースコア (_)、ハイフン (-) のみを使用してください。", - "config-connection-error": "$1。\n\n以下のホスト名、ユーザー名、パスワードを確認してから再度試してください。", + "config-connection-error": "$1。\n\n以下のホスト名、ユーザー名、パスワードを確認してから再度試してください。データベースホストとして「localhost」を使用している場合は、代わりに 「127.0.0.1」を使用してください(またはその逆)。", "config-invalid-schema": "「$1」は MediaWiki のスキーマとして無効です。\n半角の英数字 (a-z、A-Z、0-9)、アンダースコア (_) のみを使用してください。", "config-db-sys-create-oracle": "インストーラーは、新規アカウント作成にはSYSDBAアカウントの利用のみをサポートしています。", "config-db-sys-user-exists-oracle": "利用者アカウント「$1」は既に存在します。SYSDBA は新しいアカウントの作成のみに使用できます!", @@ -183,9 +183,6 @@ "config-db-web-no-create-privs": "あなたがインストールのために定義したアカウントは、アカウント作成のための特権としては不充分です。\nあなたがここで指定したアカウントは既に存在している必要があります。", "config-mysql-engine": "ストレージ エンジン:", "config-mysql-innodb": "InnoDB(推奨)", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "警告: MySQLのストレージエンジンとして MyISAM を選択していますが、これをMediaWikiで利用するのは推奨されていません。その理由は: \n* テーブルロックによる並列性をほとんどサポートしていない\n* 他のエンジンに比べて壊れやすい\n* MediaWiki のコードベースは必ずしも MyISAM を本来あるべきほどには扱っていない\n\nあなたがインストールした MySQL が InnoDB をサポートしている場合、代わりにそちらをお使いになることを強くお勧めします。\nあなたがインストールした MySQL が InnoDB をサポートしていない場合、アップグレードした方がいいでしょう。", - "config-mysql-only-myisam-dep": "警告: MyISAM がこのマシンの MySQL の唯一のストレージエンジンですが、これをMediaWikiで利用するのは推奨されていません。その理由は: \n* テーブルロックによる並列性をほとんどサポートしていない\n* 他のエンジンに比べて壊れやすい\n* MediaWiki のコードベースは必ずしも MyISAM を本来あるべきほどには扱っていない\n\nあなたがインストールした MySQL が InnoDB をサポートしていない場合、アップグレードした方がいいでしょう。", "config-mysql-engine-help": "InnoDBは、並行処理のサポートに優れているので、ほとんどの場合において最良の選択肢です。\n\nMyISAMは、利用者が1人の場合、あるいは読み込み専用でインストールする場合に、より処理が早くなるでしょう。\nただし、MyISAMのデータベースは、InnoDBより高頻度で破損する傾向があります。", "config-mssql-auth": "認証の種類:", "config-mssql-install-auth": "インストール過程でデータベースに接続するために使用する認証の種類を選択してください。\n「{{int:config-mssql-windowsauth}}」を選択した場合、ウェブサーバーを実行しているユーザーの認証情報が使用されます。", @@ -243,7 +240,7 @@ "config-license-help": "多くの公開ウィキでは、すべての寄稿物が[https://freedomdefined.org/Definition フリーライセンス]のもとに置かれています。\nこうすることにより、コミュニティによる共有の感覚が生まれ、長期的な寄稿が促されます。\n私的ウィキや企業のウィキでは、通常、フリーライセンスにする必要はありません。\n\nウィキペディアにあるテキストをあなたのウィキで利用し、逆にあなたのウィキにあるテキストをウィキペディアに複製することを許可したい場合には、{{int:config-license-cc-by-sa}}を選択するべきです。\n\nウィキペディアは以前、GNUフリー文書利用許諾契約書(GFDL)を使用していました。\nGFDLは有効なライセンスですが、内容を理解するのは困難です。\nまた、GFDLのもとに置かれているコンテンツの再利用も困難です。", "config-email-settings": "メールの設定", "config-enable-email": "メール送信を有効にする", - "config-enable-email-help": "メールを使用したい場合は、[Config-dbsupport-oracle/manual/en/mail.configuration.php PHP のメール設定]が正しく設定されている必要があります。\nメールの機能を使用しない場合は、ここで無効にすることができます。", + "config-enable-email-help": "メールを使用したい場合は、[https://www.php.net/manual/en/mail.configuration.php PHP のメール設定]が正しく設定されている必要があります。\nメールの機能を使用しない場合は、ここで無効にすることができます。", "config-email-user": "利用者間のメールを有効にする", "config-email-user-help": "設定で有効になっている場合、すべてのユーザーがお互いにメールのやりとりを行うことを許可する。", "config-email-usertalk": "利用者のトークページでの通知を有効にする", @@ -326,6 +323,7 @@ "config-install-done": "おめでとうございます!\nMediaWikiのインストールに成功しました。\n\nLocalSettings.phpファイルが生成されました。\nこのファイルはすべての設定を含んでいます。\n\nこれをダウンロードして、ウィキをインストールした基準ディレクトリ (index.phpと同じディレクトリ) に設置する必要があります。ダウンロードは自動的に開始されるはずです。\n\nダウンロードが開始されていない場合、またはダウンロードをキャンセルした場合は、下記のリンクをクリックしてダウンロードを再開できます:\n\n$3\n\n注意: この生成された設定ファイルをダウンロードせずにインストールを終了すると、このファイルは利用できなくなります。\n\n上記の作業が完了すると、[$2 ウィキに入る]ことができます。", "config-install-done-path": "おめでとうございます!\nMediaWikiのインストールに成功しました。\n\nLocalSettings.phpファイルが生成されました。\nこのファイルはすべての設定を含んでいます。\n\nこれをダウンロードして、$4 に設置する必要があります。ダウンロードは自動的に開始されるはずです。\n\nダウンロードが開始されていない場合、またはダウンロードをキャンセルした場合は、下記のリンクをクリックしてダウンロードを再開できます:\n\n$3\n\n注意: この生成された設定ファイルをダウンロードせずにインストールを終了すると、このファイルは利用できなくなります。\n\n上記の作業が完了すると、[$2 ウィキに入る]ことができます。", "config-install-success": "MediaWikiが正常にインストールされました。\n今すぐ<$1$2>にアクセスしてあなたのwikiを表示できます。\nご質問がある場合は、よくある質問リストをご覧ください:\nまたは\nそのページにリンクされているサポートフォーラム", + "config-install-db-success": "データベースは正常にセットアップされました", "config-download-localsettings": "LocalSettings.php をダウンロード", "config-help": "ヘルプ", "config-help-tooltip": "クリックで展開", diff --git a/includes/installer/i18n/ka.json b/includes/installer/i18n/ka.json index 856fa6fe70..9c4c3fb4e3 100644 --- a/includes/installer/i18n/ka.json +++ b/includes/installer/i18n/ka.json @@ -46,7 +46,6 @@ "config-invalid-db-type": "არასწორი მონაცემთა ბაზის ტიპი", "config-sqlite-readonly": "ფაილი $1 ჩასაწერად მიუწვდომელია.", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", "config-site-name": "ვიკის სახელი:", "config-site-name-blank": "შეიყვანეთ ვებ-გვერდის სახელი.", "config-project-namespace": "პროექტის სახელთა სივრცე:", diff --git a/includes/installer/i18n/ko.json b/includes/installer/i18n/ko.json index f952045cc3..a891d19361 100644 --- a/includes/installer/i18n/ko.json +++ b/includes/installer/i18n/ko.json @@ -53,7 +53,7 @@ "config-help-restart": "입력한 모든 저장된 데이터를 지우고 설치 과정을 다시 시작하겠습니까?", "config-restart": "예, 다시 시작합니다", "config-welcome": "=== 사용 환경 검사 ===\n기본 검사는 지금 이 환경이 미디어위키 설치에 적합한지 수행합니다.\n설치를 완료하는 방법에 대한 지원을 찾는다면 이 정보를 포함해야 하는 것을 기억하세요.", - "config-copyright": "=== 저작권 및 약관 ===\n\n$1\n\n이 프로그램은 자유 소프트웨어입니다. 당신은 자유 소프트웨어 재단이 발표한 GNU 일반 공중 사용 허가서 버전 2나 그 이후 버전에 따라 이 프로그램을 재배포하거나 수정할 수 있습니다.\n\n이 프로그램이 유용하게 사용될 수 있기를 바라지만 상용으로 사용되거나 특정 목적에 맞을 것이라는 것을 보증하지 않습니다.\n자세한 내용은 GNU 일반 공중 사용 허가서를 참조하십시오.\n\n당신은 이 프로그램을 통해 GNU 일반 공중 사용 허가서 전문을 받았습니다. 그렇지 않다면, Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA로 편지를 보내주시거나 [https://www.gnu.org/copyleft/gpl.html 온라인으로 읽어보시기] 바랍니다.", + "config-welcome-section-copyright": "=== 저작권 및 약관 ===\n\n$1\n\n이 프로그램은 자유 소프트웨어입니다. 당신은 자유 소프트웨어 재단이 발표한 GNU 일반 공중 사용 허가서 버전 2나 그 이후 버전에 따라 이 프로그램을 재배포하거나 수정할 수 있습니다.\n\n이 프로그램이 유용하게 사용될 수 있기를 바라지만 상용으로 사용되거나 특정 목적에 맞을 것이라는 것을 보증하지 않습니다.\n자세한 내용은 GNU 일반 공중 사용 허가서를 참조하십시오.\n\n당신은 이 프로그램을 통해 [$2 GNU 일반 공중 사용 허가서 전문]을 받았습니다. 그렇지 않다면, Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA로 편지를 보내주시거나 [https://www.gnu.org/copyleft/gpl.html 온라인으로 읽어보시기] 바랍니다.", "config-sidebar": "* [https://www.mediawiki.org 미디어위키 홈]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents 사용자 가이드]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents 관리자 가이드]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]\n----\n* 읽어보기\n* 릴리스 노트\n* 전문\n* 업그레이드하기", "config-env-good": "환경이 확인되었습니다.\n미디어위키를 설치할 수 있습니다.", "config-env-bad": "환경이 확인되었습니다.\n미디어위키를 설치할 수 없습니다.", @@ -169,9 +169,6 @@ "config-db-web-no-create-privs": "설치를 위해 지정한 계정이 계정을 만들 수 있는 충분한 권한이 없습니다.\n여기서 지정한 계정은 이미 존재해야 합니다.", "config-mysql-engine": "저장소 엔진:", "config-mysql-innodb": "InnoDB (권장)", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "경고: MySQL을 위한 저장소 엔진으로 MyISAM을 선택하였습니다. MyISAM을 미디어위키에 사용하는 것은 좋지 않습니다. 이유는:\n* 테이블 잠금 때문에 동시 실행을 지원하지 않습니다\n* 다른 엔진보다 더 손상되는 경향이 있습니다\n* 미디어위키 코드베이스가 항상 정상적으로 MyISAM을 처리하지 않습니다\n\nMySQL이 InnoDB를 지원한다면, InnoDB를 선택할 것을 매우 권장합니다.\nMySQL이 InnoDB를 지원하지 않는다면, 업그레이드를 하시는 편이 좋습니다.", - "config-mysql-only-myisam-dep": "경고: MyISAM은 이 기계에 유일하게 사용할 수 있는 MySQL용 저장소 엔진이며, 미디어위키에 사용하는 것은 좋지 않습니다. 이유는:\n* 테이블 잠금 때문에 동시 실행을 지원하지 않습니다\n* 다른 엔진보다 더 손상시키는 경향이 있습니다\n* 미디어위키 코드베이스가 항상 정상적으로 MyISAM을 처리하지 않습니다\n\n당신의 MySQL은 InnoDB를 지원하지 않으며, 업그레이드를 하는 것이 좋습니다.", "config-mysql-engine-help": "InnoDB는 동시 실행 지원이 우수하기 때문에 대부분의 경우 최고의 옵션입니다.\n\nMyISAM은 단일 사용자나 읽기 전용 설치에서 더 빠를 수 있습니다.\nMyISAM 데이터베이스는 InnoDB 데이터베이스보다 더 자주 손실될 수 있습니다.", "config-mssql-auth": "인증 형식:", "config-mssql-install-auth": "설치 과정 중 데이터베이스에 연결하는 데 사용할 인증 형식을 선택하세요.\n\"{{int:config-mssql-windowsauth}}\"을 선택하시면 웹서버를 실행 중인 아무 사용자의 자격 증명이 사용됩니다.", diff --git a/includes/installer/i18n/ksh.json b/includes/installer/i18n/ksh.json index 50cf46869b..2a23c6d5f3 100644 --- a/includes/installer/i18n/ksh.json +++ b/includes/installer/i18n/ksh.json @@ -44,7 +44,7 @@ "config-help-restart": "Wells De all Ding enjejovve Saache fottjeschmeße han, un dä janze Vörjang vun fürre aan neu aanfange?", "config-restart": "Joh, neu aanfange!", "config-welcome": "=== Ömjevong Pröhfe ===\nMer maache en Aanzahl jrundlääje Pröhvunge, öm erus ze fenge, ov di Ömjävvong heh paß för Mediawiki opzesäze.\nWann de Hölp bem Opsäze hölls, saach wigger, wat heh erus kohm, alsu wat heh schteiht.", - "config-copyright": "=== Urhävverrääsch un Lizänzbedengunge ===\n\n$1\n\nDat Projramm heh es frei, mer kann et wiggerjävve un verdeijle un och verändere onger dä Bedengunge vun de GNU General Public License (Alljemeine öffentlesche Lizänz) wi se vun de Free Software Foundation (de Schteftung för frei Projramme) veröffentlesch woode es. Dobei kanns De Der de Version 2 vun dä Lizanz ußsöhke, udder jeede Version donoh, wi et Der jefällt.\n\nDat Projramm weed wigger jejovve met dä Hoffnung, dat et jät nöz, ävver der ohne Jarrantie, sujaa der ohne de onußjeshproche Jarantie, verkoufbaa ze sin, udder för öhnds_ene beshtemmpte Zweck ze bruche ze sin.\nLiß de GNU General Public License sellver, öm mieh ze erfahre.\n\nDo sullts en Kopie vun dä alljemene öffentlesche Lizänz vun dä GNU (GNU General Public License) zosamme met heh däm Projramm krääje han. Wann dat nit esu es, schrief aan de Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, udder [https://www.gnu.org/copyleft/gpl.html liß se online övver et Internet].", + "config-welcome-section-copyright": "=== Urhävverrääsch un Lizänzbedengunge ===\n\n$1\n\nDat Projramm heh es frei, mer kann et wiggerjävve un verdeijle un och verändere onger dä Bedengunge vun de GNU General Public License (Alljemeine öffentlesche Lizänz) wi se vun de Free Software Foundation (de Schteftung för frei Projramme) veröffentlesch woode es. Dobei kanns De Der de Version 2 vun dä Lizanz ußsöhke, udder jeede Version donoh, wi et Der jefällt.\n\nDat Projramm weed wigger jejovve met dä Hoffnung, dat et jät nöz, ävver der ohne Jarrantie, sujaa der ohne de onußjeshproche Jarantie, verkoufbaa ze sin, udder för öhnds_ene beshtemmpte Zweck ze bruche ze sin.\nLiß de GNU General Public License sellver, öm mieh ze erfahre.\n\nDo sullts en [$2 Kopie vun dä alljemene öffentlesche Lizänz vun dä GNU] (GNU General Public License) zosamme met heh däm Projramm krääje han. Wann dat nit esu es, schrief aan de Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, udder [https://www.gnu.org/copyleft/gpl.html liß se online övver et Internet].", "config-sidebar": "* [https://www.mediawiki.org MediaWiki sing Hompäjdsch]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Handbohch för Aanwänder]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Handbohch för Administratohre un Wiki_Köbesse]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Öff jeschtallte Frohre met Antwoote]\n----\n* Liß Mesch! (Read me)\n* Release notes Övver heh di Projrammversion\n* Copying — Lizänzbeshtemmunge\n* Upgrading — Ob en neu Projrammversion jonn", "config-env-good": "De Ömjävvöng es jepröhf.\nDo kanns MehdijaWikki opsäze.", "config-env-bad": "De Ömjävong es jeprööf.\nDo kanns MehdijaWikki nit opsäze.", @@ -159,9 +159,6 @@ "config-db-web-no-create-privs": "Dä Zohjang för et Opsäze es nit berääschtesch, ene ander Zohjan enzereeschte.\nDä aanjejovve Zohjang för der Nomaalbedrief moß dröm schunn enjersht sen!", "config-mysql-engine": "De Zoot udder et Fommaat vun de Tabälle:", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "'''Opjepaß:''' MyISAM es als Speicher för MySQL nit besönders joot för et Zosammeschpell met MediaWiki zo bruche:\n* Dorj_et kumplätte Sperre vun Tabälle, künne koum ens Saache parrallel en dä Daatebangk jedonn wääde.\n* Dat Fomaat es anfällesch för Probleme met de Daate.\n* Et weed vun MediaWiki nit ėmmer zopaß ongerschtöz.\n\nWann Ding MySQL et Schpeischere en InnoDB-Datteije ongerschtöze deiht, dom_mer dat nohdröcklesch ämfähle.\nKann dä ẞööver dat nit, künnd et joode jelääjeheit sin, dä ens op der neuste Schtand ze bränge.", - "config-mysql-only-myisam-dep": "'''Opjepaß:''' MyISAM es de einzeje Zoot Schpeischerprojramm för MySQL op dä Maschiin. Di es nit för MediaWiki ze ämfähle es, weil:\n* wääje dem Schpärre vun jannze Tabälle sin koum paralleele Axjuhne en dä Daatebangk möjjelesch,\n* ed es aanfällesch för Probleeme met de Daate es, un\n* et weed vun MediaWiki nit emmer jood ongerschtöz.\n\nDing Enschtallazjuhn vum MySQL kann nit met InnoDB ömjonn.\nWi wöhr et med ene neuere Väsjohn vum MySQL?", "config-mysql-engine-help": "InnoDB es fö jewöhnlesch et beß, weil vill Zohjreffe op eijmohl joot ongershtöz wääde.\n\nMyISAM es flöcker op Rääschnere met bloß einem Minsch draan, un bei Wikis, di mer bloß lässe un nit schrieeve kann.\nMyISAM-Daatebangke han em Schnett mih Fähler un jon flöcker kappott, wi InnoDB-Daatebangke.", "config-mssql-auth": "De Zoot Aanmäldong:", "config-mssql-install-auth": "Söhk us, wi dat Aanmälde aan dä Daatebangk vor sesch jonn sull för de Enschtallazjuhn.\nWann De {{int:Config-mssql-windowsauth}} nemms, weed jenumme, met wat emmer dä Wäbßööver aam loufe es.", diff --git a/includes/installer/i18n/ku-latn.json b/includes/installer/i18n/ku-latn.json index 11b662e861..231271c6d2 100644 --- a/includes/installer/i18n/ku-latn.json +++ b/includes/installer/i18n/ku-latn.json @@ -40,7 +40,6 @@ "config-sqlite-readonly": "Dosyeya $1 ne nivîsbar e.", "config-db-web-account": "Hesabê danegehê bô têgihiştina tora înternetê", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", "config-site-name": "Navê wîkiyê:", "config-site-name-blank": "Navê malperek têkeve.", "config-ns-generic": "Proje", diff --git a/includes/installer/i18n/lb.json b/includes/installer/i18n/lb.json index 88d13fe31d..e6e12f1ef1 100644 --- a/includes/installer/i18n/lb.json +++ b/includes/installer/i18n/lb.json @@ -42,6 +42,7 @@ "config-restart": "Jo, neistarten", "config-welcome": "=== Iwwerpréifung vum Installatiounsenvironnement ===\nEt gi grondsätzlech Iwwerpréifunge gemaach fir ze kucken ob den Environnment gëeegent ass fir MediaWiki z'installéieren.\nDir sollt d'Resultater vun dëser Iwwerpréifung ugi wann Dir während der Installatioun Hëllef frot wéi Dir D'Installatioun ofschléisse kënnt.", "config-sidebar": "* [https://www.mediawiki.org MediaWiki Haaptsäit]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Benotzerguide]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Guide fir Administrateuren]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]\n----\n* Liest dëst\n* Informatioune vun der aktueller Versioun\n* Lizenzbedingungen\n* Aktualiséierung", + "config-sidebar-readme": "Liest dëst", "config-env-good": "Den Environement gouf nogekuckt.\nDir kënnt MediaWiki installéieren.", "config-env-bad": "Den Environnement gouf iwwerpréift.\nDir kënnt MediWiki net installéieren.", "config-env-php": "PHP $1 ass installéiert.", @@ -110,7 +111,6 @@ "config-db-web-account-same": "Dee selwechte Kont wéi bei der Installatioun benotzen", "config-db-web-create": "De Kont uleeë wann et e net scho gëtt", "config-mysql-innodb": "InnoDB (recommandéiert)", - "config-mysql-myisam": "MyISAM", "config-mssql-auth": "Typ vun der Authentifikatioun:", "config-mssql-sqlauth": "SOL-Server-Authentifikatioun", "config-mssql-windowsauth": "Windows-Authentifikatioun", diff --git a/includes/installer/i18n/lij.json b/includes/installer/i18n/lij.json index b68644421d..d555bc72b8 100644 --- a/includes/installer/i18n/lij.json +++ b/includes/installer/i18n/lij.json @@ -42,7 +42,7 @@ "config-help-restart": "Ti voeu scassâ tutti i dæti sarvæ che ti t'hæ inseio e riavviâ o processo de installaçion?", "config-restart": "Scì, riavvia", "config-welcome": "=== Controllo de l'ambiente ===\nSaiâ eseguio di controlli de base pe vedde se questo ambiente o l'è adatto pe l'installaçion de MediaWiki.\nRegordite de includde queste informaçioin se ti domandi ascistença insce comme completâ l'installaçion.", - "config-copyright": "=== Copyright e termini ===\n\n$1\n\nQuesto programma o l'è un software libero; ti poeu redistriboîlo e/ò modificâlo segondo i termi da GNU General Public License, comme pubbricâ da-a Free Software Foundation; ò a verscion 2 da Liçença ò (a proppia scelta) qualunque verscion succesciva.\n\nQuesto programma o l'è distribuio inta sperança ch'o segge utile, ma SENSA ARCUNA GARANTIA; sença manco a garantia impliçita de NEGOSSIABILITÆ o de APPRICABILITÆ PE UN PARTICOLÂ SCOPO.\nS'amie a GNU General Public License pe maggioî dettaggi.\n\nQuesto programma o dev'ese distribuio insemme a una copia da GNU General Public License; in caxo contraio, se ne poeu otegnî un-a scrivendo a-a Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA oppù [https://www.gnu.org/copyleft/gpl.html lezila inta ræ'].", + "config-welcome-section-copyright": "=== Copyright e termini ===\n\n$1\n\nQuesto programma o l'è un software libero; ti poeu redistriboîlo e/ò modificâlo segondo i termi da GNU General Public License, comme pubbricâ da-a Free Software Foundation; ò a verscion 2 da Liçença ò (a proppia scelta) qualunque verscion succesciva.\n\nQuesto programma o l'è distribuio inta sperança ch'o segge utile, ma SENSA ARCUNA GARANTIA; sença manco a garantia impliçita de NEGOSSIABILITÆ o de APPRICABILITÆ PE UN PARTICOLÂ SCOPO.\nS'amie a GNU General Public License pe maggioî dettaggi.\n\nQuesto programma o dev'ese distribuio insemme a [$2 una copia da GNU General Public License]; in caxo contraio, se ne poeu otegnî un-a scrivendo a-a Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA oppù [https://www.gnu.org/copyleft/gpl.html lezila inta ræ'].", "config-sidebar": "* [https://www.mediawiki.org Paggina prinçipâ MediaWiki]\n* [https://www.mediawiki.org/wiki/Agiutto:Guidda a-i contegnui pe utenti]\n* [https://www.mediawiki.org/wiki/Manoâ:Guidda ai contegnui per admin]\n* [https://www.mediawiki.org/wiki/Manoâ:FAQ FAQ]\n----\n* Lezime\n* Notte de verscion\n* Copie\n* Aggiornamenti", "config-env-good": "L'ambiente o l'è stæto controllou.\nL'è poscibile installâ MediaWiki.", "config-env-bad": "L'ambiente o l'è stæto controllou.\nNon l'è poscibbile installâ MediaWiki.", @@ -155,9 +155,6 @@ "config-db-web-no-create-privs": "L'account doeuviou pe l'installaçion o no dispon-e di privileggi necessai pe creâ un atro account.\nL'account indicou chì o deve za existe.", "config-mysql-engine": "Motô d'archiviaçion:", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "Atençion: t'hæ seleçionou MyISAM comme motô d'archiviaçion pe MySQL, ch'o no l'è racomandou pe l'uso con MediaWiki, percose:\n* o supporta debolmente a concorença pe-o blocco da tabella\n* o l'è ciu inclinou a-a corruçion di atri motoî\n* o codiçe de base MediaWiki o no gestisce sempre MyISAM comm'o doviæ\n\nSe a to instalaçion MySQL a supporta InnoDB, l'è atamente racomandou che ti o çerni a-o so posto.\nSe a to installaçion MySQL a no supporta InnoDB, foscia l'è o momento pe 'n agiornamento.", - "config-mysql-only-myisam-dep": "Atençion: MyISAM o l'è l'unnico motô d'archiviaçion disponibbile pe MySQL insce sta macchina, e questo no l'è consegiou pe doeuviâlo con MediaWiki, percose:\n* o supporta debolmente a concorenza pe-o blocco da tabella\n* o l''è ciu inclinou a-a corruçion di atri motoî\n* o coddiçe de base MediaWiki MyISAM o no-o gestisce sempre comm'o doviæ\n\nS'a to installaçion MySQL a no supporta InnoDB, foscia l'è o momento pe 'n agiornamento.", "config-mysql-engine-help": "InnoDB o l'è quæxi sempre a megio opçion, in quante o g'ha 'n bon supporto da concorença.\n\nMyISAM o poriæ vese ciu veloçe inte installaçioin mono-utente ò in sola-lettua.\nI database MyISAM tendan a dannezâse ciu soventi di database InnoDB.", "config-mssql-auth": "Tipo d'aotenticaçion:", "config-mssql-install-auth": "Seleçion-a o tipo d'aotenticaçion ch'o saiâ doeuviou pe conettise a-o database durante o processo de instalaçion.\nSe ti seleçion-i \"{{int:config-mssql-windowsauth}}\", saiâ doeuviou e credençiæ de quæ se segge utente segge aproeuv'a fâ giâ o serviou web.", diff --git a/includes/installer/i18n/lki.json b/includes/installer/i18n/lki.json index 9547e423b0..a637c097bf 100644 --- a/includes/installer/i18n/lki.json +++ b/includes/installer/i18n/lki.json @@ -44,7 +44,7 @@ "config-help-restart": "آیا می‌خواهید همهٔ اطلاعات ذخیره شده‌ای که وارد کرده‌اید را پاک کنید و دوباره روند نصب را شروع کنید؟", "config-restart": "أرێ، دوواره راه‌اندازی کة", "config-welcome": "===بررسی‌های محیطی===\nبرای فهمیدن اینکه این محیط برای نصب مدیاویکی مناسب است، اکنون بررسی‌های اساسی انجام خواهد‌شد.\nاگر به دنبال پشتیبانی در چگونگی تکمیل نصب هستید،به یاد داشته باشید این اطلاعات را بگنجانید.", - "config-copyright": "===حق چاپ و شرایط===\n$1\nاین برنامه، یک نرم‌افزاری آزاد است. شما می‌توانید آن را بازتوزیع کرده و/یا با شرایط نگارش ۲ یا (با نظر خودتان) هر نگارش جدیدتری از پروانه جامع همگانی گنو که توسط بنیاد نرم‌افزار آزاد منتشر شده، تغییر دهید.\n\nاین برنامه با امید این که مفید واقع‌ شود توزیع شده‌است،اما '''بدون هیچ ضمانتی'''; حتی بدون اشارهٔ ضمانتی از '''قابلیت عرضه''' یا ''' صلاحیت برای یک هدف خاص'''.\nبرای جزئیات بیش‌تر پروانه جامع همگانی گنو را مشاهده کنید.\n\nشما باید یک نگارش ازمجوز عمومی کلی همراه این برنامه دریافت کرده باشید. در غیر این صورت با بنیاد نرم‌افزار آزاد، ایالات متحده امریکا، بوستون، خیابان فرانکلین، پلاک ۵۱، طبقه پنجم، صندوق پستی MA۰۲۱۱۰-۱۳۰ مکاتبه کنید، یا [https://www.gnu.org/copyleft/gpl.html در این‌جا به صورت برخط بخوانید].", + "config-welcome-section-copyright": "===حق چاپ و شرایط===\n$1\nاین برنامه، یک نرم‌افزاری آزاد است. شما می‌توانید آن را بازتوزیع کرده و/یا با شرایط نگارش ۲ یا (با نظر خودتان) هر نگارش جدیدتری از پروانه جامع همگانی گنو که توسط بنیاد نرم‌افزار آزاد منتشر شده، تغییر دهید.\n\nاین برنامه با امید این که مفید واقع‌ شود توزیع شده‌است،اما '''بدون هیچ ضمانتی'''; حتی بدون اشارهٔ ضمانتی از '''قابلیت عرضه''' یا ''' صلاحیت برای یک هدف خاص'''.\nبرای جزئیات بیش‌تر پروانه جامع همگانی گنو را مشاهده کنید.\n\nشما باید [$2 یک نگارش ازمجوز عمومی کلی ] همراه این برنامه دریافت کرده باشید. در غیر این صورت با بنیاد نرم‌افزار آزاد، ایالات متحده امریکا، بوستون، خیابان فرانکلین، پلاک ۵۱، طبقه پنجم، صندوق پستی MA۰۲۱۱۰-۱۳۰ مکاتبه کنید، یا [https://www.gnu.org/copyleft/gpl.html در این‌جا به صورت برخط بخوانید].", "config-sidebar": "* [https://www.mediawiki.org وةڵگة اصلی مدیاویکی]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents راهنمای کاربر]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents راهنمای مدیر]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ پرسش‌های رایج]\n----\n* مرا بخوان\n* یادداشت‌های انتشار\n* نسخه برداری\n* ارتقا", "config-env-good": "محیط بررسی شده‌است.\nشما می‌توانید مدیاویکی را نصب کنید.", "config-env-bad": "محیط بررسی شده‌است.\nشما نمی‌توانید مدیاویکی را نصب کنید.", diff --git a/includes/installer/i18n/lt.json b/includes/installer/i18n/lt.json index fcb39df707..a4c524b310 100644 --- a/includes/installer/i18n/lt.json +++ b/includes/installer/i18n/lt.json @@ -99,7 +99,6 @@ "config-db-web-create": "Sukurti paskyrą, jeigu jos nėra", "config-mysql-engine": "Saugojimo variklis:", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", "config-mssql-auth": "Autentifikavimo tipas:", "config-mssql-sqlauth": "SQL Serverio autentifikavimas", "config-mssql-windowsauth": "Windows autentifikavimas", diff --git a/includes/installer/i18n/lv.json b/includes/installer/i18n/lv.json index e744495c88..675e3db1b5 100644 --- a/includes/installer/i18n/lv.json +++ b/includes/installer/i18n/lv.json @@ -43,7 +43,6 @@ "config-header-oracle": "Oracle iestatījumi", "config-header-mssql": "Microsoft SQL servera iestatījumi", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", "config-mssql-windowsauth": "Windows Autentifikācija", "config-ns-generic": "Projekts", "config-ns-site-name": "Tāds pats kā viki nosaukums: $1", diff --git a/includes/installer/i18n/mg.json b/includes/installer/i18n/mg.json index 44930c5123..a3d824bec3 100644 --- a/includes/installer/i18n/mg.json +++ b/includes/installer/i18n/mg.json @@ -37,7 +37,7 @@ "config-help-restart": "Tianao hofafana avokoa ve ny data voaangona natsofokao ary hamerina ny fizotran'ny fametrahana ?", "config-restart": "Eny, avereno atao", "config-welcome": "=== Fanamarinana mikasika ny tontolo ===\nNy fanamarihana tsotsotra dia atao hijerena raha mety ho ana rindrankajy Mediawiki ny tontolo.\nTadidio ny mametraka ireto torohay ireo raha mitady fanohanana mikasika ny fomba famaranana ny fametrahana ianao.", - "config-copyright": "== Zom-pamorona ary fepetra ==\n\n$1\n\n\nIo fandaharana dia rindrambaiko maimaim-poana; dia afaka zarazarain ary ovaina araka ny fepetra ao amin'ny GNU General Public License navoakan'ny Free Software Foundation; na versiona 2 ao amin'ny lisansa, na (araka ny safidinao) versiona tatỳ aoriana.\n\nIo fandaharaa io dia zaraina amin'ny fanantenana fa ho ilaina, anefa kosa dia tsy misy fiantohana; tsy misy fiantohana mikasika ny fivarotana azy na famendrehana ho azo ampiasaina amin'ny tranga iray manokana.\nJereo ny GNU General Public License hahazoana zavatra amin'ny antsipiriany.\n\nIanao dia tokony nandray kôpian'nyGNU General Public License miaraka amin'ny fandaharana ity; raha tsy izany, manorata any amin'ny Free Software Foundation, Inc., 51 Franklin Street, Fahadimy Floor, Boston, MA 02110-1301, USA, na [https://www.gnu.org/copyleft/gpl.html vakio ao amin'ny Internet izany].", + "config-welcome-section-copyright": "== Zom-pamorona ary fepetra ==\n\n$1\n\n\nIo fandaharana dia rindrambaiko maimaim-poana; dia afaka zarazarain ary ovaina araka ny fepetra ao amin'ny GNU General Public License navoakan'ny Free Software Foundation; na versiona 2 ao amin'ny lisansa, na (araka ny safidinao) versiona tatỳ aoriana.\n\nIo fandaharaa io dia zaraina amin'ny fanantenana fa ho ilaina, anefa kosa dia tsy misy fiantohana; tsy misy fiantohana mikasika ny fivarotana azy na famendrehana ho azo ampiasaina amin'ny tranga iray manokana.\nJereo ny GNU General Public License hahazoana zavatra amin'ny antsipiriany.\n\nIanao dia tokony nandray [$2 kôpian'nyGNU General Public License ] miaraka amin'ny fandaharana ity; raha tsy izany, manorata any amin'ny Free Software Foundation, Inc., 51 Franklin Street, Fahadimy Floor, Boston, MA 02110-1301, USA, na [https://www.gnu.org/copyleft/gpl.html vakio ao amin'ny Internet izany].", "config-sidebar": "* [https://www.mediawiki.org MediaWiki fandraisana]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Torolalan'ny mampiasa]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Torolalan'ny mpandrindra]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Fanontaniana mipetraka matetika]\n----\n* Vakio aho\n* Naoty famoahana\n* Fandikàna\n* Fampihatsaràna", "config-env-good": "Voamarina ny tontolo.\nAfaka apetrakao i MediaWiki.", "config-env-bad": "Voamarina ny tontolo.\nTsy afaka mametraka an'i MediaWiki ianao.", @@ -60,7 +60,6 @@ "config-header-mssql": "Parametatry ny lohamilina Microsoft SQL Server", "config-invalid-db-type": "Karazana banky angona tsy ekena.", "config-mysql-innodb": "innoDB", - "config-mysql-myisam": "MyISAM", "config-ns-generic": "Tetikasa", "config-ns-other": "Hafa (lazao)", "config-admin-name": "Ny anaranao :", diff --git a/includes/installer/i18n/mk.json b/includes/installer/i18n/mk.json index 450fa8bf73..e6526837a8 100644 --- a/includes/installer/i18n/mk.json +++ b/includes/installer/i18n/mk.json @@ -45,17 +45,21 @@ "config-help-restart": "Дали сакате да ги исчистите сите зачувани податоци што ги внесовте и да ја започнете воспоставката одново?", "config-restart": "Да, почни одново", "config-welcome": "=== Проверки на околината ===\nСега ќе се извршиме основни проверки за да се востанови дали околината е погодна за воспоставкa на МедијаВики. Не заборавајте да ги приложите овие информации ако барате помош со довршување на воспоставката.", - "config-copyright": "=== Авторски права и услови ===\n\n$1\n\nОва е слободна програмска опрема (free software); можете да го редистрибуирате и/или менувате согласно условите на ГНУ-овата општа јавна лиценца (GNU General Public License) на Фондацијата за слободна програмска опрема (Free Software Foundation); верзија 2 или било која понова верзија на лиценцата (по ваш избор).\n\nОвој програм се нуди со надеж дека ќе биде корисен, но '''без никаква гаранција'''; дури ни подразбраната гаранција за '''продажна способност''' или '''погодност за определена цел'''.\nПовеќе информации ќе најдете во текстот на ГНУ-овата општа јавна лиценца.\n\nБи требало да имате добиено примерок од ГНУ-овата општа јавна лиценца заедно со програмов; ако немате добиено, тогаш пишете ни на Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. или [https://www.gnu.org/copyleft/gpl.html прочитајте ја тука].", - "config-sidebar": "* [https://www.mediawiki.org Домашна страница на МедијаВики]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Водич за корисници]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Водич за администратори]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ ЧПП]\n----\n* Прочитај ме\n* Белешки за изданието\n* Копирање\n* Надградување", + "config-welcome-section-copyright": "=== Авторски права и услови ===\n\n$1\n\nОва е слободна програмска опрема (free software); можете да го редистрибуирате и/или менувате согласно условите на ГНУ-овата општа јавна лиценца (GNU General Public License) на Фондацијата за слободна програмска опрема (Free Software Foundation); верзија 2 или било која понова верзија на лиценцата (по ваш избор).\n\nОвој програм се нуди со надеж дека ќе биде корисен, но '''без никаква гаранција'''; дури ни подразбраната гаранција за '''продажна способност''' или '''погодност за определена цел'''.\nПовеќе информации ќе најдете во текстот на ГНУ-овата општа јавна лиценца.\n\nБи требало да имате добиено [$2 примерок од ГНУ-овата општа јавна лиценца] заедно со програмов; ако немате добиено, тогаш пишете ни на Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. или [https://www.gnu.org/copyleft/gpl.html прочитајте ја тука].", + "config-sidebar": "* [https://www.mediawiki.org Домашна страница на МедијаВики]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Водич за корисници]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Водич за администратори]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ ЧПП]", + "config-sidebar-readme": "Прочитај ме", + "config-sidebar-relnotes": "Белешки за изданието", + "config-sidebar-license": "Копирање", + "config-sidebar-upgrade": "Надградба", "config-env-good": "Околината е проверена.\nМожете да го воспоставите МедијаВики.", "config-env-bad": "Околината е проверена.\nНе можете да го воспоставите МедијаВики.", "config-env-php": "PHP $1 е воспоставен.", "config-env-hhvm": "HHVM $1 е воспоставен.", - "config-unicode-using-intl": "Со додатокот [https://pecl.php.net/intl intl PECL] за уникодна нормализација.", - "config-unicode-pure-php-warning": "'''Предупредување''': Додатокот [https://pecl.php.net/intl intl PECL] не е достапен за врши уникодна нормализација, враќајќи се на бавна примена на чист PHP.\n\nАко имате високопрометно мрежно место, тогаш ќе треба да прочитате повеќе за [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations уникодната нормализација].", + "config-unicode-using-intl": "Со додатокот [https://php.net/manual/en/book.intl.php intl PECL] за уникодна нормализација.", + "config-unicode-pure-php-warning": "'''Предупредување''': Додатокот [https://php.net/manual/en/book.intl.php intl PECL] не е достапен за врши уникодна нормализација, враќајќи се на бавна примена на чист PHP.\n\nАко имате високопрометно мрежно место, тогаш ќе треба да прочитате повеќе за [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations уникодната нормализација].", "config-unicode-update-warning": "'''Предупредување:''' Воспоставената верзија на обвивката за уникодна нормализација користи постара верзија на библиотеката на [http://site.icu-project.org/ проектот ICU].\nЗа да користите Уникод, ќе треба да направите [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations надградба].", "config-no-db": "Не можев да најдам соодветен двигател за базата на податоци! Ќе треба да воспоставите двигател за PHP-база.\n{{PLURAL:$2|Поддржан се следниов вид|Поддржани се следниве видови}} бази: $1.\n\nДоколку самите го срочивте овој PHP, овозможете го базниот клиент во поставките — на пр. со ./configure --with-mysqli.\nАко овој PHP го воспоставите од пакет на Debian или Ubuntu, тогаш ќе треба исто така да го воспоставите, на пр., пакетот php-mysql.", - "config-outdated-sqlite": "'''Предупредување''': имате SQLite $1. Најстарата допуштена верзија е $2. Затоа, SQLite ќе биде недостапен.", + "config-outdated-sqlite": "Предупредување: имате SQLite $2. Најстарата допуштена верзија е $1. Затоа, SQLite ќе биде недостапен.", "config-no-fts3": "'''Предупредување''': SQLite iе составен без модулот [//sqlite.org/fts3.html FTS3] - за оваа база нема да има можност за пребарување.", "config-pcre-old": "'''Кобно:''' Се бара PCRE $1 или понова верзија.\nВашиот PHP-бинарен е сврзан со PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Повеќе информации].", "config-pcre-no-utf8": "Кобно: PCRE-модулот на PHP е срочен без поддршка за PCRE_UTF8.\nМедијаВики бара поддршка за UTF-8 за да може да работи правилно.", @@ -163,9 +167,6 @@ "config-db-web-no-create-privs": "Сметката што ја назначивте за воспоставка нема доволно привилегии за да може да создаде сметка.\nТука мора да назначите постоечка сметка.", "config-mysql-engine": "Складишен погон:", "config-mysql-innodb": "InnoDB (препорачано)", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "'''Предупредување:''' Го одбравте MyISAM како складишен погон за MySQL. Но тој не се препорачува за МедијаВики бидејќи:\n* одвај поддржува едновременост поради заклучување на табелите\n* поподложен на расипување од другите погони\n* кодната база на МедијаВики не секогаш може да работи со MyISAM како што треба\n\nАко вашата воспоставка на MySQL поддржува InnoDB, тогаш сериозно препорачуваме да го користите него наместо MyISAM.\nАко вашата воспоставка на MySQL не поддржува InnoDB, веројатно дошло време за надградба.", - "config-mysql-only-myisam-dep": "'''Предупредување:''' MyISAM е единствениот достапен складишен погон за MySQL на оваа машина, а ова не се препорачува за употреба со МедијаВики, бидејќи:\n* речиси не поддржува истовремено извршување на задачите поради заклучувањето на табелите\n* поподложен е на расипувања од другите погони\n* кодната база на МедијаВИки не секогаш работи исправно со MyISAM\nВашата воспоставка на MySQL не поддржува InnoDB. Можеби е време да ја надградите.", "config-mysql-engine-help": "'''InnoDB''' речиси секогаш е најдобар избор, бидејќи има добра поддршка за едновременост.\n\n'''MyISAM''' може да е побрз кај воспоставките наменети за само еден корисник или незаписни воспоставки (само читање).\nБазите на податоци од MyISAM почесто се расипуваат од базите на InnoDB.", "config-mssql-auth": "Тип на заверка:", "config-mssql-install-auth": "Изберете го типот на заверка што ќе се користи за поврзување со базата на податоци во текот на воспоставката.\nАко изберете „{{int:config-mssql-windowsauth}}“, ќе се користат најавните податоци или корисникот како кој работи мрежниот опслужувач.", @@ -223,7 +224,7 @@ "config-license-help": "Многу јавни викија ги ставаат сите придонеси под [https://freedomdefined.org/Definition слободна лиценца].\nСо ова се создава атмосфера на општа сопственост и поттикнува долгорочно учество.\nОва не е неопходно за викија на поединечни физички или правни лица.\n\nАко сакате да користите текст од Википедија, и сакате Википедија да прифаќа текст прекопиран од вашето вики, тогаш треба да ја одберете лиценцата {{int:config-license-cc-by-sa}}..\n\nГНУ-овата лиценца за слободна документација (ГЛСД) е старата лиценца на Википедија.\nОваа лиценца сè уште важи, но е тешка за разбирање.\nИсто така треба да се има на ум дека пренамената на содржините под ГЛСД не е лесна.", "config-email-settings": "Нагодувања за е-пошта", "config-enable-email": "Овозможи излезна е-пошта", - "config-enable-email-help": "Ако сакате да работи е-поштата, [Config-dbsupport-oracle/manual/en/mail.configuration.php поштенските нагодувања на PHP] треба да се правилно наместени.\nАко воопшто не сакате никакви функции за е-пошта, тогаш можете да ги оневозможите тука.", + "config-enable-email-help": "Ако сакате да работи е-поштата, [https://www.php.net/manual/en/mail.configuration.php поштенските нагодувања на PHP] треба да се правилно наместени.\nАко воопшто не сакате никакви функции за е-пошта, тогаш можете да ги оневозможите тука.", "config-email-user": "Овозможи е-пошта од корисник до корисник", "config-email-user-help": "Дозволи сите корисници да можат да си праќаат е-пошта ако ја имаат овозможено во нагодувањата.", "config-email-usertalk": "Овозможи известувања за промени во кориснички страници за разговор", diff --git a/includes/installer/i18n/mr.json b/includes/installer/i18n/mr.json index 67d9f54b26..9f31fda72b 100644 --- a/includes/installer/i18n/mr.json +++ b/includes/installer/i18n/mr.json @@ -42,7 +42,7 @@ "config-help-restart": "आपण टाकून जतन केलेला सर्व डाटा आपणास साफ करावयाचा व उभारणीची प्रक्रिया पुन्हा सुरू करावयाची आहे काय?", "config-restart": "होय, परत चालू करा", "config-welcome": "=== पारिसरीक तपासण्या ===\nमिडियाविकिच्या उभारणीस हा परिसर योग्य आहे काय याच्या मूळ तपासण्या आता केल्या जातील.\nजर आपणास पुढे याची उभारणी करण्याबद्दल साहाय्य लागल्यास, याचा अंतर्भाव करणे लक्षात ठेवा.", - "config-copyright": "=== प्रताधिकार व अटी ===\n\n$1\nहा कार्यसंच,हे एक मुक्त संचेतन आहे;आपण त्यास पुनर्वितरीत व/किंवा त्यास फ्री सॉफ्टवेअर फाऊंडेशन द्वारे प्रकाशित, GNU जनरल पब्लिक लायसन्स अंतर्गत बदलु शकता;या परवान्याची आवृत्ती २ किंवा (आपल्या इच्छेनुसार)त्यानंतरची आवृत्ती.\n\nहा कार्यसंचाचे वितरण,पण, कोणत्याही हमीशिवाय; याशिवाय व्यापारीकरणाच्या कोणत्याही अभिप्रेत आश्वासनाशिवाय किंवा एखाद्या विशिष्ट कार्यासाठीच्या अर्हतेशिवायही आशा ठेऊन केले आहे कि, तो उपयोगी असेल.\nअधिक माहितीसाठी GNU जनरल पब्लिक लायसन्स बघा.\nआपणास या कार्यसंचासमवेत GNU जनरल पब्लिक लायसन्सची प्रत मिळाली असेल,नसल्यास,फ्री सॉफ्टवेअर फाऊंडेशनला या पत्त्यावर लिहा.Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. किंवा त्यास [https://www.gnu.org/copyleft/gpl.html ऑनलाईन वाचा].", + "config-welcome-section-copyright": "=== प्रताधिकार व अटी ===\n\n$1\nहा कार्यसंच,हे एक मुक्त संचेतन आहे;आपण त्यास पुनर्वितरीत व/किंवा त्यास फ्री सॉफ्टवेअर फाऊंडेशन द्वारे प्रकाशित, GNU जनरल पब्लिक लायसन्स अंतर्गत बदलु शकता;या परवान्याची आवृत्ती २ किंवा (आपल्या इच्छेनुसार)त्यानंतरची आवृत्ती.\n\nहा कार्यसंचाचे वितरण,पण, कोणत्याही हमीशिवाय; याशिवाय व्यापारीकरणाच्या कोणत्याही अभिप्रेत आश्वासनाशिवाय किंवा एखाद्या विशिष्ट कार्यासाठीच्या अर्हतेशिवायही आशा ठेऊन केले आहे कि, तो उपयोगी असेल.\nअधिक माहितीसाठी GNU जनरल पब्लिक लायसन्स बघा.\nआपणास या कार्यसंचासमवेत [$2 GNU जनरल पब्लिक लायसन्सची प्रत मिळाली असेल,]नसल्यास,फ्री सॉफ्टवेअर फाऊंडेशनला या पत्त्यावर लिहा.Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. किंवा त्यास [https://www.gnu.org/copyleft/gpl.html ऑनलाईन वाचा].", "config-sidebar": "* [https://www.mediawiki.org मिडियाविकि गृह]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents सदस्य मार्गदर्शिका]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents प्रशासकाची मार्गदर्शिका]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ एफएक्यू]\n----\n* रीड मी\n* विमोचन टिप्पण्या\n* नकलविणे\n* दर्जोन्नती करणे", "config-env-good": "पारिसरीक तपासणी झाली आहे.\nआपण मिडियाविकि उभारू शकता.", "config-env-bad": "पारिसरीक तपासणी झाली आहे.\nआपण मिडियाविकि उभारू शकत नाही.", diff --git a/includes/installer/i18n/ms.json b/includes/installer/i18n/ms.json index 0318f64898..1163486095 100644 --- a/includes/installer/i18n/ms.json +++ b/includes/installer/i18n/ms.json @@ -47,7 +47,7 @@ "config-help-restart": "Adakah anda ingin untuk membersihkan semua data yang disimpan yang anda telah masukkan dan memulakan semula proses pemasangan?", "config-restart": "Ya, mula semula", "config-welcome": "=== Pemeriksaan persekitaran ===\nPemeriksaan asas kini boleh dilakukan untuk melihat jika persekitaran ini adalah sesuai untuk pemasangan MediaWiki.\nIngat untuk memasukkan maklumat ini jika anda mahukan sokongan tentang bagaimana untuk menyelesaikan pemasangan.", - "config-copyright": "=== Hakcipta dan Syarat-Syarat ===\n\n$1\n\nProgram ini merupakan perisian bebas; anda boleh mengedarkannya semula dan/atau mengubahsuainya di bawah syarat-syarat Lesen Awam GNU seperti yang diterbitkan oleh Yayasan Perisian Bebas; sama ada versi 2 Lesen ini atau (mengikut pilihan anda) mana-mana versi selepas ini.\n\nProgram ini diedarkan dengan harapan bahawa ia akan menjadi berguna, tetapi '''tanpa sebarang waranti'''; tanpa jaminan yang tersirat '''kebolehdagangan''' atau '''kesesuaian untuk tujuan tertentu'''.\nLihat Lesen Awam GNU untuk maklumat lanjut.\n\nAnda sepatutnya telah menerima satu salinan Lesen Awam GNU bersama-sama dengan program ini, jika tidak, menulis surat kepada Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, atau [https://www.gnu.org/copyleft/gpl.html membacanya dalam talian].", + "config-welcome-section-copyright": "=== Hakcipta dan Syarat-Syarat ===\n\n$1\n\nProgram ini merupakan perisian bebas; anda boleh mengedarkannya semula dan/atau mengubahsuainya di bawah syarat-syarat Lesen Awam GNU seperti yang diterbitkan oleh Yayasan Perisian Bebas; sama ada versi 2 Lesen ini atau (mengikut pilihan anda) mana-mana versi selepas ini.\n\nProgram ini diedarkan dengan harapan bahawa ia akan menjadi berguna, tetapi '''tanpa sebarang waranti'''; tanpa jaminan yang tersirat '''kebolehdagangan''' atau '''kesesuaian untuk tujuan tertentu'''.\nLihat Lesen Awam GNU untuk maklumat lanjut.\n\nAnda sepatutnya telah menerima [$2 satu salinan Lesen Awam GNU ] bersama-sama dengan program ini, jika tidak, menulis surat kepada Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, atau [https://www.gnu.org/copyleft/gpl.html membacanya dalam talian].", "config-sidebar": "* [https://www.mediawiki.org Laman utama MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Panduan Pengguna]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Panduan Penyelia]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Soalan lazim]\n----\n* Baca saya\n* Nota keluaran\n* Menyalin\n* Menaik taraf", "config-env-good": "Persekitaran telah diperiksa.\nAnda boleh memasang MediaWiki.", "config-env-bad": "Persekitaran telah diperiksa. \nAnda tidak boleh memasang MediaWiki.", @@ -96,8 +96,6 @@ "config-db-web-create": "Ciptakan akaun jika belum wujud", "config-mysql-engine": "Enjin storan:", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", - "config-mysql-only-myisam-dep": "Amaran: MyISAM ialah satu-satunya enjin storan yang terdapat untuk MySQL di mesin ini, dan penggunaannya dengan MediaWiki tidak digalakkan kerana:\n* ia tidak menyokong keserempakan (''concurrency'') disebabkan penguncian jadual\n* ia lebih terdedah kepada korupsi daripada enjin-enjin lain\n* pangkalan kod MediaWiki tidak sentiasa mengendalikan MyISAM seperti yang diharapkan\n\nPemasangan MySQL anda tidak menyokong InnoDB. Mungkin tiba masanya untuk naik taraf.", "config-mssql-auth": "Jenis pengesahan:", "config-site-name": "Nama wiki:", "config-site-name-help": "Ini akan dipaparkan pada bar tajuk perisian pelayar dan tempat-tempat lain yang berkenaan.", diff --git a/includes/installer/i18n/mt.json b/includes/installer/i18n/mt.json index c4cd28b4a8..dae12805e4 100644 --- a/includes/installer/i18n/mt.json +++ b/includes/installer/i18n/mt.json @@ -41,7 +41,6 @@ "config-db-schema": "Skema għal MediaWiki:", "config-db-web-create": "Oħloq il-kont jekk għadu ma jeżistix", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", "config-site-name": "Isem tal-wiki:", "config-site-name-help": "Dan se jidher fil-barra tat-titlu tal-browżer u f'diversi postijiet oħra.", "config-site-name-blank": "Daħħal isem tas-sit.", diff --git a/includes/installer/i18n/nan.json b/includes/installer/i18n/nan.json index 67c20e0a7f..c90f8168f9 100644 --- a/includes/installer/i18n/nan.json +++ b/includes/installer/i18n/nan.json @@ -43,7 +43,7 @@ "config-help-restart": "你敢欲共你拍的佮保存的資料攏清掉,並且重開始安裝的動作?", "config-restart": "是,重來", "config-welcome": "=== 環境檢測 ===\n這馬欲做基本的檢測,看環境是毋是適合裝 MediaWiki。\n若你愛有支援,才裝會起來,請共遮的資訊記起來。", - "config-copyright": "=== Pán-koân seng-bêng kap siū-koân tiâu-khoán ===\n\n$1\n\nPún têng-sek sī chū-iû nńg-thé; lí thang chiàu Chū-iû Nńg-thé Ki-kim-hoē só͘ hoat-piáu--ê GNU Thong-iōng Kong-kiōng Siū-koân Tiâu-khoán kui-tēng, kā pún têng-sek têng hoat-pò͘ iah-sī siu-kái; bô-lūn lí sī chiàu pún siū-koân tiâu-khoán--ê tē 2 pán iah koh khah sin--ê pán-pún (lí thang ka-kī kéng).\n\nPún têng-sek hoat-pò͘--ê bo̍k-tek sī ǹg-bāng ē-tàng pang-chān, m̄-koh bô hù jīm-hô tam-pó͘ chek-jīm; iah bô piáu-sī kóng tùi hoàn-bē-sèng iah te̍k-tēng iōng-tô͘--ê sek-iōng-sèng--ê chêng-hêng tam-pó͘. Siông-sè chhiáⁿ chham-khó GNU Thong-iōng Kong-kiōng Siū-koân.\n\nLí èng-kai tùi pún têng-sek siu-tio̍h GNU Thong-iōng Kong-kiōng Siū-koân--ê Hù-pún; nā-bô, chhiáⁿ siá-phoe thong-tī Chū-iû Nńg-thé KI-kim-hoē, 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, iah-sī [https://www.gnu.org/copyleft/gpl.html soàⁿ-téng khoàⁿ].", + "config-welcome-section-copyright": "=== Pán-koân seng-bêng kap siū-koân tiâu-khoán ===\n\n$1\n\nPún têng-sek sī chū-iû nńg-thé; lí thang chiàu Chū-iû Nńg-thé Ki-kim-hoē só͘ hoat-piáu--ê GNU Thong-iōng Kong-kiōng Siū-koân Tiâu-khoán kui-tēng, kā pún têng-sek têng hoat-pò͘ iah-sī siu-kái; bô-lūn lí sī chiàu pún siū-koân tiâu-khoán--ê tē 2 pán iah koh khah sin--ê pán-pún (lí thang ka-kī kéng).\n\nPún têng-sek hoat-pò͘--ê bo̍k-tek sī ǹg-bāng ē-tàng pang-chān, m̄-koh bô hù jīm-hô tam-pó͘ chek-jīm; iah bô piáu-sī kóng tùi hoàn-bē-sèng iah te̍k-tēng iōng-tô͘--ê sek-iōng-sèng--ê chêng-hêng tam-pó͘. Siông-sè chhiáⁿ chham-khó GNU Thong-iōng Kong-kiōng Siū-koân.\n\nLí èng-kai tùi pún têng-sek siu-tio̍h [$2 GNU Thong-iōng Kong-kiōng Siū-koân--ê Hù-pún]; nā-bô, chhiáⁿ siá-phoe thong-tī Chū-iû Nńg-thé KI-kim-hoē, 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, iah-sī [https://www.gnu.org/copyleft/gpl.html soàⁿ-téng khoàⁿ].", "config-sidebar": "* [www.mediawiki.org/wiki/MediaWiki/zh-hant MediaWiki 頭頁]\n* [www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents/zh 使用者指南]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents/zh 管理者指南]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/zh-hant 四常問題集]\n----\n* 讀我說明\n* 發行說明\n* 版權聲明\n* 升級", "config-env-good": "環境檢查已完成。\n你會當安裝 MediaWiki。", "config-env-bad": "Khoân-kèng kiám-cha oân-sêng--ah.\nLí bô-hoat-tō͘ an-chng MediaWiki.", diff --git a/includes/installer/i18n/nap.json b/includes/installer/i18n/nap.json index d4dc4b61f4..5379e8da5f 100644 --- a/includes/installer/i18n/nap.json +++ b/includes/installer/i18n/nap.json @@ -44,7 +44,7 @@ "config-help-restart": "Vulite scancellà tutt' 'e date astipate c'avite nzertato e riabbià 'o prucesso d'installazione?", "config-restart": "Sì, riabbìa", "config-welcome": "=== Cuntrollo 'e ll'ambiente ===\nSarranno eseguite 'e cuntrolle bbase pe' putè vedè si st'ambiente è adatto pe' ne ffà l'installazione 'e MediaWiki.\nArricurdateve d'includere sti nfurmaziune si spiate assistenza ncopp' 'a maniera 'e cumpletà l'installazione.", - "config-copyright": "=== Copyright e termine ===\n\n$1\n\nChistu programma è nu software libbero; vuje 'o putite redestribbuì e/o cagnà sott' 'e termine d' 'a licienza GNU GPL ('a Licienza Pubbreca Generale) comme pubbrecata d' 'a Free Software Foundation; o pure 'a verziona 2 d' 'a Licienza, o pure (comme vulite vuje) 'a n'ata verziona cchiù nnova.\n\nChistu programma è destribbuito c' 'a speranza d'essere utile, ma SENZA NISCIUNA GARANZIA; senza manco 'a garanzia p' 'a CUMMERCIABBELETÀ O IDONIETÀ PE' NU SCOPO PARTICULARE.\nIate a vedé 'a GNU GPL pe' n'avé cchiù nfurmaziune.\n\nCu stu programma avísseve 'a ricevere na copia d' 'a Licienza GNU GPL cu stu prugramma; si nò, scrivete â Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA o [https://www.gnu.org/copyleft/gpl.html liggite sta paggena ncopp' 'a l'Internet].", + "config-welcome-section-copyright": "=== Copyright e termine ===\n\n$1\n\nChistu programma è nu software libbero; vuje 'o putite redestribbuì e/o cagnà sott' 'e termine d' 'a licienza GNU GPL ('a Licienza Pubbreca Generale) comme pubbrecata d' 'a Free Software Foundation; o pure 'a verziona 2 d' 'a Licienza, o pure (comme vulite vuje) 'a n'ata verziona cchiù nnova.\n\nChistu programma è destribbuito c' 'a speranza d'essere utile, ma SENZA NISCIUNA GARANZIA; senza manco 'a garanzia p' 'a CUMMERCIABBELETÀ O IDONIETÀ PE' NU SCOPO PARTICULARE.\nIate a vedé 'a GNU GPL pe' n'avé cchiù nfurmaziune.\n\nCu stu programma avísseve 'a ricevere [$2 na copia d' 'a Licienza GNU GPL] cu stu prugramma; si nò, scrivete â Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA o [https://www.gnu.org/copyleft/gpl.html liggite sta paggena ncopp' 'a l'Internet].", "config-sidebar": "* [https://www.mediawiki.org Paggina prencepale MediaWiki]\n* [https://www.mediawiki.org/wiki/Aiuto:Guida a 'e cuntenute pe' l'utente]\n* [https://www.mediawiki.org/wiki/Manuale:Guida a 'e cuntenute pe l'ammenistrature]\n* [https://www.mediawiki.org/wiki/Manuale:FAQ FAQ]\n----\n* Lieggeme\n* Note 'e verziona\n* Copie\n* Agghiurnamento", "config-env-good": "L'ambiente è stato cuntrullato.\nÈ pussibbele installare MediaWiki.", "config-env-bad": "L'ambiente è stato cuntrullato.\nNun se può installà MediaWiki.", @@ -158,9 +158,6 @@ "config-db-web-no-create-privs": "'O cunto ausato pe' ne fà l'installazione nun tene diritte necessarie pe' ne putè crià n'atu cunto.\n'O cunto zegnàto ccà adda esistere già.", "config-mysql-engine": "Mutore d'astipo:", "config-mysql-innodb": "InnoDB (fosse 'o cunzigliato)", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "Attenziò: avite scigliuto MyISAM comm' 'o mutore 'archiviaziona MySQL, ca nun è raccummannato pe' l'ausà cu MediaWiki, pecché:\n* supporta debolmente 'a concorrenza p' 'o blocco d' 'a tabbella\n* è cchiù inchine 'a corruzione 'e l'ati mutore\n* 'o codece 'e base 'e MediaWiki nun gestisce sempe MyISAM comme l'avess'a gistiunà\n\nSi ll'installazione vosta MySQL suppuorta InnoDB, è autamente raccummandato ca si scigliesse a 'o posto suo.\nSi 'a installazione MySQL nun suppurtasse InnoDB, forse è 'o mumento 'e ll'agghiurnà.", - "config-mysql-only-myisam-dep": "Attenziò: MyISAM è l'uneco mutore p'astipà date ca se trova mo' a disposizione p' 'o MySQL dint'a sta macchina, e nun fosse raccumandato 'e s'ausà cu MediaWiki, pecché:\n* suppurtasse minimamente concorrenza pe' bbìa 'e bluccà tabbelle\n* è cchiù facile ca jesse a se scassà cchiù 'e l'ati mutore\n* 'o codece MediaWiki nun maniasse sempe MyISAM comme l'avesse 'e manià\n\nL'installazione MySQL nun suppurtasse InnoDB, può darse ca chist'è 'o mumento pe' ve ll'agghiurnà.", "config-mysql-engine-help": "InnoDB è quase sempe 'a meglia opzione, pecché ave nu buono suppuorto concorrente.\n\nMyISAM putesse ghì cchiù ampressa int'a na installazione mono-utente e liegge-surtanto.\n'E database MyISAM se scassano cchiù spisso d' 'e database InnoDB.", "config-mssql-auth": "Tipo d'autenticazione:", "config-mssql-install-auth": "Sceglie 'o tipo d'autenticazziona ca s'ausarrà pe cunnettà â database, durante ll'operazziona d'istallazziona. Si piglie \"{{int:config-mssql-windowsauth}}\", 'e credenziale 'e qualunque fosse ll'utenza ca 'o webserver sta pruciessanno sarranno ausate.", diff --git a/includes/installer/i18n/nb.json b/includes/installer/i18n/nb.json index fc299fb91c..522294f440 100644 --- a/includes/installer/i18n/nb.json +++ b/includes/installer/i18n/nb.json @@ -49,7 +49,7 @@ "config-help-restart": "Ønsker du å fjerne alle lagrede data som du har skrevet inn og starte installasjonsprosessen på nytt?", "config-restart": "Ja, start på nytt", "config-welcome": "=== Miljøsjekker ===\nGrunnleggende sjekker utføres for å se om dette miljøet er egnet for en MediaWiki-installasjon.\nDu bør oppgi resultatene fra disse sjekkene om du trenger hjelp under installasjonen.", - "config-copyright": "=== Opphavsrett og vilkår ===\n\n$1\n\nMediaWiki er fri programvare; du kan redistribuere det og/eller modifisere det under betingelsene i GNU General Public License som publisert av Free Software Foundation; enten versjon 2 av lisensen, eller (etter eget valg) enhver senere versjon.\n\nDette programmet er distribuert i håp om at det vil være nyttig, men '''uten noen garanti'''; ikke engang implisitt garanti av '''salgbarhet''' eller '''egnethet for et bestemt formål'''.\nSe GNU General Public License for flere detaljer.\n\nDu skal ha mottatt en kopi av GNU General Public License sammen med dette programmet; hvis ikke, skriv til Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA eller [https://www.gnu.org/copyleft/gpl.html les det på nettet].", + "config-welcome-section-copyright": "=== Opphavsrett og vilkår ===\n\n$1\n\nMediaWiki er fri programvare; du kan redistribuere det og/eller modifisere det under betingelsene i GNU General Public License som publisert av Free Software Foundation; enten versjon 2 av lisensen, eller (etter eget valg) enhver senere versjon.\n\nDette programmet er distribuert i håp om at det vil være nyttig, men '''uten noen garanti'''; ikke engang implisitt garanti av '''salgbarhet''' eller '''egnethet for et bestemt formål'''.\nSe GNU General Public License for flere detaljer.\n\nDu skal ha mottatt [$2 en kopi av GNU General Public License] sammen med dette programmet; hvis ikke, skriv til Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA eller [https://www.gnu.org/copyleft/gpl.html les det på nettet].", "config-sidebar": "* [https://www.mediawiki.org MediaWiki hjem]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Brukerguide]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Administratorguide]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ OSS]\n----\n* Les meg\n* Utgivelsesnotater\n* Kopiering\n* Oppgradering", "config-env-good": "Miljøet har blitt sjekket.\nDu kan installere MediaWiki.", "config-env-bad": "Miljøet har blitt sjekket.\nDu kan ikke installere MediaWiki.", @@ -167,9 +167,6 @@ "config-db-web-no-create-privs": "Kontoen du oppga for installasjonen har ikke nok privilegier til å opprette en konto.\nKontoen du oppgir her må finnes allerede.", "config-mysql-engine": "Lagringsmotor:", "config-mysql-innodb": "InnoDB (anbefalt)", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "'''Advarsel:''' Du har valgt MyISAM som lagringsmotor for MySQL, noe som ikke er anbefalt for bruk med MediaWiki, fordi:\n* den knapt støtter samtidighet pga. tabell-låsing\n* den har større tilbøyelighet for å bli korrupt enn andre motorer\n* MediaWiki-koden håndterer ikke alltid MyISAM som den burde\n\nHvis din MySQL-installasjon støtter InnoDB, er det sterkt å anbefale at du i stedet velger den.\nHvis din MySQL-installasjon ikke støtter InnoDB, kan det være på tide med en oppgradering.", - "config-mysql-only-myisam-dep": "'''Advarsel:''' MyISAM er den eneste tilgjengelig lagringsmotoren for MySQL på denne maskinen, og det er ikke anbefalt brukt for MediaWiki, fordi:\n* den knapt støtter samtidighet pga. tabell-låsing\n* den har større tilbøyelighet for å bli korrupt enn andre motorer\n* MediaWiki-koden håndterer ikke alltid MyISAM som den burde\n\nHvis din MySQL-installasjon ikke støtter InnoDB, kan det være på tide med en oppgradering.", "config-mysql-engine-help": "'''InnoDB''' er nesten alltid det beste alternativet siden den har god støtte for samtidighet («concurrency»).\n\n'''MyISAM''' kan være raskere i enbruker- eller les-bare-installasjoner.\nMyISAM-databaser har en tendens til å bli ødelagt oftere enn InnoDB-databaser.", "config-mssql-auth": "Autentiseringstype:", "config-mssql-install-auth": "Valg autentiseringstypen som skal brukes for å koble til databasen under installeringsprosessen. Hvis du velger «{{int:config-mssql-windowsauth}}», vil påloggingsinformasjonen for brukeren som kjører webtjeneren blir brukt.", diff --git a/includes/installer/i18n/nl-informal.json b/includes/installer/i18n/nl-informal.json index 49e0e58e72..3450712796 100644 --- a/includes/installer/i18n/nl-informal.json +++ b/includes/installer/i18n/nl-informal.json @@ -14,7 +14,7 @@ "config-your-language": "Jouw taal:", "config-help-restart": "Wil je alle opgeslagen gegevens die je hebt ingevoerd wissen en het installatieproces opnieuw starten?", "config-welcome": "=== Controle omgeving ===\nEr worden een aantal basiscontroles uitgevoerd met als doel vast te stellen of deze omgeving geschikt is voor een installatie van MediaWiki.\nAls je hulp nodig hebt bij de installatie, lever deze gegevens dan ook aan.", - "config-copyright": "=== Auteursrechten en voorwaarden ===\n\n$1\n\nDit programma is vrije software. Je mag het verder verspreiden en/of aanpassen in overeenstemming met de voorwaarden van de GNU General Public License zoals uitgegeven door de Free Software Foundation; ofwel versie 2 van de Licentie of - naar eigen keuze - enige latere versie.\n\nDit programma wordt verspreid in de hoop dat het nuttig is, maar '''zonder enige garantie''', zelfs zonder de impliciete garantie van '''verkoopbaarheid''' of '''geschiktheid voor een bepaald doel'''.\nZie de GNU General Public License voor meer informatie.\n\nSamen met dit programma hoor je een exemplaar van de GNU General Public License ontvangen te hebben; zo niet, schrijf dan aan de Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, Verenigde Staten. Of [https://www.gnu.org/copyleft/gpl.html lees de licentie online].", + "config-welcome-section-copyright": "=== Auteursrechten en voorwaarden ===\n\n$1\n\nDit programma is vrije software. Je mag het verder verspreiden en/of aanpassen in overeenstemming met de voorwaarden van de GNU General Public License zoals uitgegeven door de Free Software Foundation; ofwel versie 2 van de Licentie of - naar eigen keuze - enige latere versie.\n\nDit programma wordt verspreid in de hoop dat het nuttig is, maar '''zonder enige garantie''', zelfs zonder de impliciete garantie van '''verkoopbaarheid''' of '''geschiktheid voor een bepaald doel'''.\nZie de GNU General Public License voor meer informatie.\n\nSamen met dit programma hoor je een [$2 exemplaar van de GNU General Public License] ontvangen te hebben; zo niet, schrijf dan aan de Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, Verenigde Staten. Of [https://www.gnu.org/copyleft/gpl.html lees de licentie online].", "config-env-good": "De omgeving is gecontroleerd.\nJe kunt MediaWiki installeren.", "config-env-bad": "De omgeving is gecontroleerd.\nJe kunt MediaWiki niet installeren.", "config-unicode-pure-php-warning": "'''Waarschuwing''': de [https://pecl.php.net/intl PECL-extensie intl] is niet beschikbaar om de Unicodenormalisatie af te handelen en daarom wordt de langzame PHP-implementatie gebruikt.\nAls je MediaWiki voor een website met veel verkeer installeert, lees je dan in over [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicodenormalisatie].", @@ -44,7 +44,6 @@ "config-upgrade-done": "Het bijwerken is afgerond.\n\nJe kunt [$1 je wiki nu gebruiken].\n\nAls je je LocalSettings.php opnieuw wilt aanmaken, klik dan op de knop hieronder.\nDit is '''niet aan te raden''' tenzij je problemen hebt met je wiki.", "config-upgrade-done-no-regenerate": "Het bijwerken is afgerond.\n\nJe kunt nu [$1 je wiki gebruiken].", "config-db-web-no-create-privs": "Het account dat je voor installatie hebt opgegeven, heeft niet voldoende rechten om een account aan te maken.\nHet account dat je hier opgeeft, moet al bestaan.", - "config-mysql-myisam-dep": "'''Waarschuwing''': je hebt MyISAM geselecteerd als opslagengine voor MySQL. Dit is niet aan te raden voor MediaWiki omdat:\n* het nauwelijks ondersteuning biedt voor gebruik door meerdere gebruikers tegelijkertijd door het locken van tabellen;\n* het meer vatbaar is voor corruptie dan andere engines;\n* de code van MediaWiki niet alstijd omgaat met MyISAM zoals dat zou moeten.\n\nAls je installatie van MySQL InnoDB ondersteunt, gebruik dat dan vooral.\nAls je installatie van MySQL geen ondersteuning heeft voor InnoDB, denk dan na over upgraden.", "config-project-namespace-help": "In het kielzog van Wikipedia beheren veel wiki's hun beleidspagina's apart van hun inhoudelijke pagina's in een \"'''projectnaamruimte'''\".\nAlle paginanamen in deze naamruimte beginnen met een bepaald voorvoegsel dat je hier kunt opgeven.\nDit voorvoegsel wordt meestal afgeleid van de naam van de wiki, maar het kan geen bijzondere tekens bevatten als \"#\" of \":\".", "config-admin-name": "Je naam:", "config-admin-password-mismatch": "De twee door jou ingevoerde wachtwoorden komen niet overeen.", diff --git a/includes/installer/i18n/nl.json b/includes/installer/i18n/nl.json index 1b34fc6466..23835cd36e 100644 --- a/includes/installer/i18n/nl.json +++ b/includes/installer/i18n/nl.json @@ -63,14 +63,18 @@ "config-help-restart": "Wilt u alle opgeslagen gegevens die u hebt ingevoerd wissen en het installatieproces opnieuw starten?", "config-restart": "Ja, opnieuw starten", "config-welcome": "=== Controle omgeving ===\nEr worden een aantal basiscontroles uitgevoerd met als doel vast te stellen of deze omgeving geschikt is voor een installatie van MediaWiki.\nLever deze gegevens aan als u ondersteuning vraagt bij de installatie.", - "config-copyright": "=== Auteursrechten en voorwaarden ===\n\n$1\n\nDit programma is vrije software. U mag het verder verspreiden en/of aanpassen in overeenstemming met de voorwaarden van de GNU General Public License zoals uitgegeven door de Free Software Foundation; ofwel versie 2 van de Licentie of - naar uw keuze - enige latere versie.\n\nDit programma wordt verspreid in de hoop dat het nuttig is, maar '''zonder enige garantie''', zelfs zonder de impliciete garantie van '''verkoopbaarheid''' of '''geschiktheid voor een bepaald doel'''.\nZie de GNU General Public License voor meer informatie.\n\nSamen met dit programma hoort u een exemplaar van de GNU General Public License ontvangen te hebben; zo niet, schrijf dan aan de Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, Verenigde Staten. Of [https://www.gnu.org/copyleft/gpl.html lees de licentie online].", - "config-sidebar": "* [https://www.mediawiki.org MediaWiki-thuispagina]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Gebruikershandleiding] (Engelstalig)\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Beheerdershandleiding] (Engelstalig)\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Veelgestelde vragen] (Engelstalig)\n----\n* Leesmij (Engelstalig)\n* Release notes (Engelstalig)\n* Kopiëren (Engelstalig)\n* Versie bijwerken (Engelstalig)", + "config-welcome-section-copyright": "=== Auteursrechten en voorwaarden ===\n\n$1\n\nDit programma is vrije software. U mag het verder verspreiden en/of aanpassen in overeenstemming met de voorwaarden van de GNU General Public License zoals uitgegeven door de Free Software Foundation; ofwel versie 2 van de Licentie of - naar uw keuze - enige latere versie.\n\nDit programma wordt verspreid in de hoop dat het nuttig is, maar '''zonder enige garantie''', zelfs zonder de impliciete garantie van '''verkoopbaarheid''' of '''geschiktheid voor een bepaald doel'''.\nZie de GNU General Public License voor meer informatie.\n\nSamen met dit programma hoort u een [$2 exemplaar van de GNU General Public License] ontvangen te hebben; zo niet, schrijf dan aan de Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, Verenigde Staten. Of [https://www.gnu.org/copyleft/gpl.html lees de licentie online].", + "config-sidebar": "* [https://www.mediawiki.org MediaWiki-thuispagina]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Gebruikershandleiding]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Beheerdershandleiding]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Veelgestelde vragen]", + "config-sidebar-readme": "Leesmij", + "config-sidebar-relnotes": "Release notes", + "config-sidebar-license": "Licentie", + "config-sidebar-upgrade": "Informatie over het bijwerken", "config-env-good": "De omgeving is gecontroleerd.\nU kunt MediaWiki installeren.", "config-env-bad": "De omgeving is gecontroleerd.\nU kunt MediaWiki niet installeren.", "config-env-php": "PHP $1 is geïnstalleerd.", "config-env-hhvm": "HHVM $1 is geïnstalleerd.", - "config-unicode-using-intl": "Voor Unicode-normalisatie wordt de [https://pecl.php.net/intl PECL-extensie intl] gebruikt.", - "config-unicode-pure-php-warning": "Waarschuwing: de [https://pecl.php.net/intl PECL-extensie intl] is niet beschikbaar om de Unicodenormalisatie af te handelen en daarom wordt de langzamere PHP-implementatie gebruikt.\nAls u MediaWiki voor een website met veel verkeer installeert, lees u dan in over [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicodenormalisatie].", + "config-unicode-using-intl": "Voor Unicode-normalisatie wordt de [https://php.net/manual/en/book.intl.php PHP-extensie intl] gebruikt.", + "config-unicode-pure-php-warning": "Waarschuwing: de [https://php.net/manual/en/book.intl.php PHP-uitbreiding intl] is niet beschikbaar om de Unicodenormalisatie af te handelen en daarom wordt de langzamere PHP-implementatie gebruikt.\nAls u MediaWiki voor een website met veel verkeer installeert, lees u dan in over [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicodenormalisatie].", "config-unicode-update-warning": "Waarschuwing: de geïnstalleerde versie van de Unicodenormalisatiewrapper maakt gebruik van een oudere versie van [http://site.icu-project.org/ de bibliotheek van het ICU-project].\nU moet [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations bijwerken] als Unicode voor u van belang is.", "config-no-db": "Het was niet mogelijk een geschikte databasedriver te vinden voor PHP! U moet een databasedriver installeren voor PHP.\n{{PLURAL:$2|Het volgende databasetype wordt|De volgende databasetypes worden}} ondersteund: $1.\n\nAls u PHP zelf hebt gecompileerd, wijzig dan uw instellingen zodat een databasedriver wordt geactiveerd, bijvoorbeeld via ./configure --with-mysqli.\nAls u PHP hebt geïnstalleerd via een Debian- of Ubuntu-package, installeer dan ook bijvoorbeeld de module php-mysql.", "config-outdated-sqlite": "''' Waarschuwing:''' u gebruikt SQLite $2. SQLite is niet beschikbaar omdat de minimaal vereiste versie $1 is.", @@ -181,9 +185,6 @@ "config-db-web-no-create-privs": "Het account dat u voor de installatie hebt opgegeven, heeft niet voldoende rechten om een account aan te maken.\nHet account dat u hier opgeeft, moet al bestaan.", "config-mysql-engine": "Opslagmethode:", "config-mysql-innodb": "InnoDB (aanbevolen)", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "'''Waarschuwing''': u hebt MyISAM geselecteerd als opslagengine voor MySQL. Dit is niet aan te raden voor MediaWiki omdat:\n* het nauwelijks ondersteuning biedt voor gebruik door meerdere gebruikers tegelijkertijd door het locken van tabellen;\n* het meer vatbaar is voor corruptie dan andere engines;\n* de code van MediaWiki niet alstijd omgaat met MyISAM zoals dat zou moeten.\n\nAls uw installatie van MySQL InnoDB ondersteunt, gebruik dat dan vooral.\nAls uw installatie van MySQL geen ondersteuning heeft voor InnoDB, denk dan na over upgraden.", - "config-mysql-only-myisam-dep": "'''Waarschuwing:''' MyISAM is enige beschikbare opslagmethode voor MySQL in deze omgeving, en deze wordt niet aangeraden voor gebruik met MediaWiki, omdat:\n* er nauwelijks ondersteuning is voor meerdere gelijktijdige transacties omdat tabellen op slot gezet worden;\n* tabellen makkelijker stuk kunnen gaan;\n* de code van MediaWiki niet altijd op de juiste wijze omgaat met MyISAM.\n\nUw installatie van MySQL heeft geen ondersteuning voor InnoDB. We raden u aan om een meer recente versie te gebruiken.", "config-mysql-engine-help": "'''InnoDB''' is vrijwel altijd de beste instelling, omdat deze goed omgaat met meerdere verzoeken tegelijkertijd.\n\n'''MyISAM''' is bij een zeer beperkt aantal gebruikers mogelijk sneller, of als de wiki alleen-lezen is.\nMyISAM-databases raken vaker beschadigd dan InnoDB-databases.", "config-mssql-auth": "Authenticatietype:", "config-mssql-install-auth": "Selecteer de authenticatiemethode die wordt gebruikt om met de database te verbinden tijdens het installatieproces.\nAls u \"{{int:config-mssql-windowsauth}}\" selecteert, dan worden de aanmeldgegevens van de gebruiker waaronder de webserver draait voor authenticatie gebruikt.", diff --git a/includes/installer/i18n/oc.json b/includes/installer/i18n/oc.json index 7a99bb82d4..3aaecb53f7 100644 --- a/includes/installer/i18n/oc.json +++ b/includes/installer/i18n/oc.json @@ -91,7 +91,6 @@ "config-db-web-create": "Creatz lo compte se existís pas ja", "config-mysql-engine": "Motor d'emmagazinatge :", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", "config-mssql-auth": "Tipe d’autentificacion :", "config-mssql-sqlauth": "Autentificacion de SQL Server", "config-mssql-windowsauth": "Autentificacion Windows", diff --git a/includes/installer/i18n/olo.json b/includes/installer/i18n/olo.json index 6a518c75e3..6fdcd8204f 100644 --- a/includes/installer/i18n/olo.json +++ b/includes/installer/i18n/olo.json @@ -35,7 +35,6 @@ "config-header-oracle": "Oracle-azetukset", "config-header-mssql": "Microsoft SQL Server azetukset", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", "config-site-name": "Wikin nimi:", "config-site-name-blank": "Kirjuta sivun nimi.", "config-project-namespace": "Projektan nimitila:", diff --git a/includes/installer/i18n/pl.json b/includes/installer/i18n/pl.json index e8f9078f5b..feda6b2cf7 100644 --- a/includes/installer/i18n/pl.json +++ b/includes/installer/i18n/pl.json @@ -64,14 +64,18 @@ "config-help-restart": "Czy chcesz usunąć wszystkie zapisane dane i uruchomić ponownie proces instalacji?", "config-restart": "Tak, zacznij od nowa", "config-welcome": "=== Sprawdzenie środowiska instalacji ===\nTeraz zostaną wykonane podstawowe testy sprawdzające czy to środowisko jest odpowiednie dla instalacji MediaWiki.\nJeśli potrzebujesz pomocy podczas instalacji, załącz wyniki tych testów.", - "config-copyright": "=== Prawa autorskie i warunki użytkowania ===\n\n$1\n\nTo oprogramowanie jest wolne; możesz je rozprowadzać dalej i modyfikować zgodnie z warunkami licencji GNU General Public License opublikowanej przez Free Software Foundation w wersji 2 tej licencji lub (według Twojego wyboru) którejś z późniejszych jej wersji.\n\nNiniejsze oprogramowanie jest rozpowszechniane w nadziei, że będzie użyteczne, ale '''bez żadnej gwarancji'''; nawet bez domniemanej gwarancji '''handlowej''' lub '''przydatności do określonego celu'''.\nZobacz treść licencji GNU General Public License, aby uzyskać więcej szczegółów.\n\nRazem z oprogramowaniem powinieneś otrzymać kopię licencji GNU General Public License. Jeśli jej nie otrzymałeś, napisz do Free Software Foundation, Inc, 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. lub [https://www.gnu.org/copyleft/gpl.html przeczytaj ją online].", - "config-sidebar": "* [https://www.mediawiki.org Strona domowa MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Podręcznik użytkownika]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Podręcznik administratora]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Odpowiedzi na często zadawane pytania]\n----\n* Przeczytaj to\n* Informacje o tej wersji\n* Kopiowanie\n* Aktualizacja", + "config-welcome-section-copyright": "=== Prawa autorskie i warunki użytkowania ===\n\n$1\n\nTo oprogramowanie jest wolne; możesz je rozprowadzać dalej i modyfikować zgodnie z warunkami licencji GNU General Public License opublikowanej przez Free Software Foundation w wersji 2 tej licencji lub (według Twojego wyboru) którejś z późniejszych jej wersji.\n\nNiniejsze oprogramowanie jest rozpowszechniane w nadziei, że będzie użyteczne, ale '''bez żadnej gwarancji'''; nawet bez domniemanej gwarancji '''handlowej''' lub '''przydatności do określonego celu'''.\nZobacz treść licencji GNU General Public License, aby uzyskać więcej szczegółów.\n\nRazem z oprogramowaniem powinieneś otrzymać [$2 kopię licencji GNU General Public License]. Jeśli jej nie otrzymałeś, napisz do Free Software Foundation, Inc, 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. lub [https://www.gnu.org/copyleft/gpl.html przeczytaj ją online].", + "config-sidebar": "* [https://www.mediawiki.org Strona domowa MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Podręcznik użytkownika]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Podręcznik administratora]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Odpowiedzi na często zadawane pytania]", + "config-sidebar-readme": "Podstawowe informacje", + "config-sidebar-relnotes": "Informacje o wersji", + "config-sidebar-license": "Kopiowanie", + "config-sidebar-upgrade": "Uaktualnienie", "config-env-good": "Środowisko oprogramowania zostało sprawdzone.\nMożesz teraz zainstalować MediaWiki.", "config-env-bad": "Środowisko oprogramowania zostało sprawdzone.\nNie możesz zainstalować MediaWiki.", "config-env-php": "Zainstalowane jest PHP w wersji $1.", "config-env-hhvm": "Zainstalowany jest HHVM $1.", - "config-unicode-using-intl": "Korzystanie z [https://pecl.php.net/intl rozszerzenia intl PECL] do normalizacji Unicode.", - "config-unicode-pure-php-warning": "Uwaga: [https://pecl.php.net/intl Rozszerzenie intl PECL] do obsługi normalizacji Unicode nie jest dostępne. Użyta zostanie mało wydajna zwykła implementacja w PHP.\nJeśli prowadzisz stronę o dużym natężeniu ruchu, powinieneś zapoznać się z informacjami o [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalizacji Unicode].", + "config-unicode-using-intl": "Korzystanie z [https://php.net/manual/en/book.intl.php rozszerzenia PHP intl] do normalizacji Unicode.", + "config-unicode-pure-php-warning": "Uwaga: [https://php.net/manual/en/book.intl.php rozszerzenie PHP intl] do obsługi normalizacji Unicode nie jest dostępne. Użyta zostanie mało wydajna zwykła implementacja w PHP.\nJeśli prowadzisz stronę o dużym natężeniu ruchu, powinieneś zapoznać się z informacjami o [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalizacji Unicode].", "config-unicode-update-warning": "Uwaga: zainstalowana wersja normalizacji Unicode korzysta z nieaktualnej biblioteki [http://site.icu-project.org/ projektu ICU].\nPowinieneś [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations wykonać aktualizację], jeśli chcesz korzystać w pełni z Unicode.", "config-no-db": "Nie można odnaleźć właściwego sterownika bazy danych! Musisz zainstalować sterownik bazy danych dla PHP.\nMożna użyć {{PLURAL:$2|następującego typu bazy|następujących typów baz}} danych: $1.\n\nJeśli skompilowałeś PHP samodzielnie, skonfiguruj go ponownie z włączonym klientem bazy danych, na przykład za pomocą polecenia ./configure --with-mysqli.\nJeśli zainstalowałeś PHP jako pakiet Debiana lub Ubuntu, musisz również zainstalować np. moduł php-mysql.", "config-outdated-sqlite": "Ostrzeżenie: masz SQLite $2, która jest niższa od minimalnej wymaganej wersji $1 . SQLite będzie niedostępne.", @@ -179,9 +183,6 @@ "config-db-web-no-create-privs": "Konto podane do wykonania instalacji nie ma wystarczających uprawnień, aby utworzyć nowe konto.\nKonto, które wskazałeś tutaj musi już istnieć.", "config-mysql-engine": "Silnik przechowywania", "config-mysql-innodb": "InnoDB (zalecane)", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "'''Ostrzeżenie''': wybrano MyISIAM jako silnik składowania danych MySQL, co nie jest zalecane do użytku w MediaWiki, ponieważ:\n * ledwo obsługuje współbieżnośći ze względu na blokowanie tabel\n * jest bardziej podatna na uszkodzenie niż inne silniki\n * kod źródłowy MediaWiki nie zawsze obsługuje MyISAM tak, jak powinien\n\nJeśli instalacja MySQL obsługuje InnoDB, jest wysoce zalecane, by to je wybrać.\nJeśli instalacja MySQL nie obsługuje InnoDB, być może nadszedł czas na jej uaktualnienie.", - "config-mysql-only-myisam-dep": "'''Ostrzeżenie:''' MyISAM jest jedynym dostępnym na tym komputerze mechanizmem składowania dla MySQL, który jednak nie jest zalecany do używania z MediaWiki, ponieważ:\n* słabo obsługuje współbieżność z powodu blokowania tabel\n* jest bardziej skłonny do uszkodzeń niż inne silniki\n* kod MediaWiki nie zawsze traktuje MyISAM jak powinien\n\nTwoja instalacja MySQL nie obsługuje InnoDB, być może jest to czas na aktualizację.", "config-mysql-engine-help": "'''InnoDB''' jest prawie zawsze najlepszą opcją, ponieważ posiada dobrą obsługę współbieżności.\n\n'''MyISAM''' może być szybsze w instalacjach pojedynczego użytkownika lub tylko do odczytu.\nBazy danych MyISAM mają tendencję do ulegania uszkodzeniom częściej niż bazy InnoDB.", "config-mssql-auth": "Typ uwierzytelniania:", "config-mssql-install-auth": "Wybierz typ uwierzytelniania, który będzie używany do łączenia się z bazą danych w trakcie procesu instalacji.\nJeśli wybierzesz „{{int:config-mssql-windowsauth}}”, będą wykorzystywane dane konta użytkownika, pod którym działa serwer www.", diff --git a/includes/installer/i18n/pms.json b/includes/installer/i18n/pms.json index 130d507d1c..b6ab572056 100644 --- a/includes/installer/i18n/pms.json +++ b/includes/installer/i18n/pms.json @@ -48,7 +48,7 @@ "config-help-restart": "Veul-lo scancelé tùit ij dat salvà ch'a l'ha anserì e anandié torna ël process d'instalassion?", "config-restart": "É!, felo torna parte", "config-welcome": "=== Contròj d'ambient ===\nDle verìfiche ëd base a saran adess fàite për vëdde se st'ambient a va bin për l'instalassion ëd MediaWiki.\nCh'as visa d'anserì coste anformassion s'a sërca d'agiut su com completé l'instalassion.", - "config-copyright": "=== Drit d'Autor e Condission ===\n\n$1\n\nCost-sì a l'é un programa lìber e a gràtis: a peul ridistribuilo e/o modifichelo sota le condission dla licensa pùblica general GNU com publicà da la Free Software Foundation; la version 2 dla Licensa, o (a toa sèrnìa) qualsëssìa version pi recenta.\n\nCost programa a l'é distribuì ant la speransa ch'a sia ùtil, ma '''sensa gnun-e garansìe'''; sensa gnanca la garansia implìssita ëd '''comersiabilità''' o '''d'esse adat a un but particolar'''.\n\nA dovrìa avèj arseivù na còpia ëd la licensa pùblica general GNU ansema a sto programa; dësnò, ch'a scriva a la Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA opura [https://www.gnu.org/copyleft/gpl.html ch'a la lesa an linia].", + "config-welcome-section-copyright": "=== Drit d'Autor e Condission ===\n\n$1\n\nCost-sì a l'é un programa lìber e a gràtis: a peul ridistribuilo e/o modifichelo sota le condission dla licensa pùblica general GNU com publicà da la Free Software Foundation; la version 2 dla Licensa, o (a toa sèrnìa) qualsëssìa version pi recenta.\n\nCost programa a l'é distribuì ant la speransa ch'a sia ùtil, ma '''sensa gnun-e garansìe'''; sensa gnanca la garansia implìssita ëd '''comersiabilità''' o '''d'esse adat a un but particolar'''.\n\nA dovrìa avèj arseivù [$2 na còpia ëd la licensa pùblica general GNU] ansema a sto programa; dësnò, ch'a scriva a la Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA opura [https://www.gnu.org/copyleft/gpl.html ch'a la lesa an linia].", "config-sidebar": "* [https://www.mediawiki.org Intrada MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Guida dl'Utent]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Guida dl'Aministrator]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Soens an ciamo]\n----\n* Ch'am lesa\n* Nòte ëd publicassion\n* Còpia\n* Agiornament", "config-env-good": "L'ambient a l'é stàit controlà.\nIt peule instalé MediaWiki.", "config-env-bad": "L'ambient a l'é stàit controlà.\nIt peule pa instalé MediaWiki.", @@ -151,8 +151,6 @@ "config-db-web-no-create-privs": "Ël cont ch'a l'ha specificà për l'instalassion a l'ha pa basta 'd privilegi për creé un cont.\nËl cont ch'a spessìfica ambelessì a dev già esiste.", "config-mysql-engine": "Motor ëd memorisassion:", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "'''Avis''': A l'ha selessionà MyISAM com motor ëd memorisassion për MySQL, che a l'é pa arcomandà da dovré con MediaWiki, përchè:\n* a sopòrta a pen-a la contemporanità për via ëd saradure ëd tàula\n* a l'é pi soget a la corussion che j'àutri motor\n* ël còdes bas ëd MediaWiki pa sempe a gestiss MyISAM com a dovrìa\n\nSe soa istalassion MySQL a manten InnoDB, a l'é fortement arcomandà ch'a serna pitòst col-lì.\nSe soa istalassion MySQL a manten nen InnoDB, a peul esse ch'a sia ël moment ëd n'agiornament.", "config-mysql-engine-help": "'''InnoDB''' a l'é scasi sempe la mej opsion, da già ch'a l'ha un bon manteniment dla concorensa.\n\n'''MyISAM''' a peul esse pi lest an instalassion për n'utent sol o mach an letura.\nLa base ëd dàit MyISAM a tira a corompse pi 'd soens che la base ëd dàit InnoDB.", "config-site-name": "Nòm ëd la wiki:", "config-site-name-help": "Sòn a comparirà ant la bara dël tìtol dël navigador e an vàire d'àutri pòst.", diff --git a/includes/installer/i18n/pt-br.json b/includes/installer/i18n/pt-br.json index a17ca697dd..5d1cc8fd02 100644 --- a/includes/installer/i18n/pt-br.json +++ b/includes/installer/i18n/pt-br.json @@ -63,14 +63,18 @@ "config-help-restart": "Deseja limpar todos os dados salvos que você introduziu e reiniciar o processo de instalação?", "config-restart": "Sim, reiniciar", "config-welcome": "=== Verificações de ambiente ===\nSerão realizadas verificações básicas para determinar se este ambiente é apropriado para a instalação do MediaWiki.\nLembre-se de incluir estas informações se for procurar por suporte para como concluir a instalação.", - "config-copyright": "=== Direitos autorais e Termos de uso ===\n\n$1\n\nEste programa é software livre; você pode redistribuí-lo e/ou modificá-lo nos termos da licença GNU General Public License tal como publicada pela Free Software Foundation; tanto a versão 2 da Licença, como (por opção sua) qualquer versão posterior.\n\nEste programa é distribuído na esperança de que seja útil, mas sem qualquer garantia; inclusive, sem a garantia implícita da possibilidade de ser comercializado ou de adequação para qualquer finalidade específica.\nConsulte a licença GNU General Public License para mais detalhes.\n\nEm conjunto com este programa você deve ter recebido uma cópia da licença GNU General Public License; se não a recebeu, peça-a por escrito para Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA ou [https://www.gnu.org/copyleft/gpl.html leia-a na internet].", - "config-sidebar": "* [https://www.mediawiki.org/wiki/MediaWiki Página principal do MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Manual do usuário]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Manual do administrador]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]\n----\n* Leia-me\n* Notas de lançamento\n* Licença\n* Atualizando", + "config-welcome-section-copyright": "=== Direitos autorais e Termos de uso ===\n\n$1\n\nEste programa é software livre; você pode redistribuí-lo e/ou modificá-lo nos termos da licença GNU General Public License tal como publicada pela Free Software Foundation; tanto a versão 2 da Licença, como (por opção sua) qualquer versão posterior.\n\nEste programa é distribuído na esperança de que seja útil, mas sem qualquer garantia; inclusive, sem a garantia implícita da possibilidade de ser comercializado ou de adequação para qualquer finalidade específica.\nConsulte a licença GNU General Public License para mais detalhes.\n\nEm conjunto com este programa você deve ter recebido [$2 uma cópia da licença GNU General Public License]; se não a recebeu, peça-a por escrito para Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA ou [https://www.gnu.org/copyleft/gpl.html leia-a na internet].", + "config-sidebar": "* [https://www.mediawiki.org/wiki/MediaWiki/pt-br Página principal do MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents/pt-br Ajuda]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents/pt-br Manual técnico]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/pt-br FAQ]", + "config-sidebar-readme": "Leia-me", + "config-sidebar-relnotes": "Notas de lançamento", + "config-sidebar-license": "Copiar", + "config-sidebar-upgrade": "Atualizar", "config-env-good": "O ambiente foi verificado.\nVocê pode instalar o MediaWiki.", "config-env-bad": "O ambiente foi verificado.\nVocê não pode instalar o MediaWiki.", "config-env-php": "O PHP $1 está instalado.", "config-env-hhvm": "O HHVM $1 está instalado.", - "config-unicode-using-intl": "Usando a [https://pecl.php.net/intl extensão intl PECL] para a normalização Unicode.", - "config-unicode-pure-php-warning": "Aviso: A [https://pecl.php.net/intl extensão intl PECL] não está disponível para efetuar a normalização Unicode, abortando e passando para a lenta implementação de PHP puro.\nSe o seu site tem um alto volume de tráfego, informe-se sobre a [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalização Unicode].", + "config-unicode-using-intl": "Usando a [https://www.php.net/manual/pt_BR/book.intl.php extensão intl PHP] para a normalização Unicode.", + "config-unicode-pure-php-warning": "Aviso: A [https://www.php.net/manual/pt_BR/book.intl.php extensão intl PHP] não está disponível para efetuar a normalização Unicode, abortando e passando para a lenta implementação de PHP puro.\nSe o seu site tem um alto volume de tráfego, informe-se sobre a [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalização Unicode].", "config-unicode-update-warning": "Aviso: A versão instalada do wrapper de normalização Unicode usa uma versão mais antiga da biblioteca do [http://www.site.icu-project.org/projeto ICU].\nVocê deve [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations atualizar] se você tem quaisquer preocupações com o uso do Unicode.", "config-no-db": "Não foi possível encontrar um driver apropriado para a banco de dados! Você precisa instalar um driver de banco de dados para PHP. {{PLURAL:$2|É aceito o seguinte tipo|São aceitos os seguintes tipos}} de banco de dados: $1.\n\nSe você compilou o PHP, reconfigure-o com um cliente de banco de dados ativado, por exemplo, usando ./configure --with-mysqli.\nSe instalou o PHP a partir de um pacote Debian ou Ubuntu, então também precisa instalar, por exemplo, o pacote php-mysql.", "config-outdated-sqlite": "Aviso: você tem o SQLite versão $2, que é menor do que a versão mínima necessária $1. O SQLite não estará disponível.", @@ -181,9 +185,6 @@ "config-db-web-no-create-privs": "A conta que você especificou para a instalação não possui privilégios suficientes para criar uma conta.\nA conta que for especificada aqui já deve existir.", "config-mysql-engine": "Mecanismo de armazenamento:", "config-mysql-innodb": "InnoDB (recomendado)", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "Aviso: Você selecionou MyISAM como mecanismo de armazenamento para o MySQL, o que não é recomendado para uso com o MediaWiki, porque:\n* dificilmente suporta concorrência devido ao bloqueio da tabela\n* é mais propenso à corrupção do que outros motores\n*a base de código MediaWiki nem sempre lida com o MyISAM como deveria\n\nSe sua instalação MySQL suportar o InnoDB, é altamente recomendável que você escolha ele.\nSe sua instalação MySQL não suportar o InnoDB, talvez seja hora de uma atualização.", - "config-mysql-only-myisam-dep": "Aviso: O MyISAM é o único mecanismo de armazenamento disponível para o MySQL nesta máquina e isso não é recomendado para uso com o MediaWiki, porque:\n* dificilmente suporta concorrência devido ao bloqueio da tabela\n* é mais propenso à corrupção do que outros motores\n*a base de código MediaWiki nem sempre lida com o MyISAM como deveria\n\nA sua instalação no MySQL não suporta InnoDB, talvez seja hora de uma atualização.", "config-mysql-engine-help": "InnoDB é quase sempre a melhor opção, uma vez que possui um bom suporte de concorrência.\n\nMyISAM pode ser mais rápido em instalações de usuário único ou somente leitura.\\O banco de dados MyISAM tendem a ficar corrompidos mais frequentemente do que os bancos de dados InnoDB.", "config-mssql-auth": "Tipo de autenticação:", "config-mssql-install-auth": "Selecione o tipo de autenticação que será usado para se conectar ao banco de dados durante o processo de instalação.\nSe você selecionar \"{{int:config-mssql-windowsauth}}\", as credenciais de qualquer usuário que o servidor web esteja executando serão usadas.", diff --git a/includes/installer/i18n/pt.json b/includes/installer/i18n/pt.json index 8768afc536..223e26ede2 100644 --- a/includes/installer/i18n/pt.json +++ b/includes/installer/i18n/pt.json @@ -21,7 +21,8 @@ "Seb35", "MokaAkashiyaPT", "Athena in Wonderland", - "CaiusSPQR" + "CaiusSPQR", + "Waldyrious" ] }, "config-desc": "O instalador do MediaWiki", @@ -61,17 +62,21 @@ "config-help-restart": "Deseja limpar todos os dados gravados que introduziu e reiniciar o processo de instalação?", "config-restart": "Sim, reiniciar", "config-welcome": "=== Verificações do ambiente ===\nSerão agora realizadas verificações básicas para determinar se este ambiente é apropriado para instalação do MediaWiki.\nLembre-se de fornecer esta informação se necessitar de pedir ajuda para concluir a instalação.", - "config-copyright": "=== Direitos de autor e Condições de utilização ===\n\n$1\n\nEste programa é software livre; pode redistribuí-lo e/ou modificá-lo nos termos da licença GNU General Public License, tal como publicada pela Free Software Foundation; tanto a versão 2 da Licença, como (por opção sua) qualquer versão posterior.\n\nEste programa é distribuído na esperança de que seja útil, mas '''sem qualquer garantia'''; inclusive, sem a garantia implícita da '''possibilidade de ser comercializado''' ou de '''adequação para qualquer finalidade específica'''.\nConsulte a licença GNU General Public License para mais detalhes.\n\nEm conjunto com este programa deve ter recebido uma cópia da licença GNU General Public License; se não a recebeu, peça-a por escrito a Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA ou [https://www.gnu.org/copyleft/gpl.html leia-a na Internet].", - "config-sidebar": "* [https://www.mediawiki.org/wiki/MediaWiki/pt Página principal do MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents/pt Ajuda]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents/pt Manual técnico]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]\n----\n* Leia-me\n* Notas de lançamento\n* Cópia\n* Atualização", + "config-welcome-section-copyright": "=== Direitos de autor e Condições de utilização ===\n\n$1\n\nEste programa é software livre; pode redistribuí-lo e/ou modificá-lo nos termos da licença GNU General Public License, tal como publicada pela Free Software Foundation; tanto a versão 2 da Licença, como (por opção sua) qualquer versão posterior.\n\nEste programa é distribuído na esperança de que seja útil, mas '''sem qualquer garantia'''; inclusive, sem a garantia implícita da '''possibilidade de ser comercializado''' ou de '''adequação para qualquer finalidade específica'''.\nConsulte a licença GNU General Public License para mais detalhes.\n\nEm conjunto com este programa deve ter recebido [$2 uma cópia da licença GNU General Public License]; se não a recebeu, peça-a por escrito a Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA ou [https://www.gnu.org/copyleft/gpl.html leia-a na Internet].", + "config-sidebar": "* [https://www.mediawiki.org/wiki/MediaWiki/pt Página principal do MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents/pt Ajuda]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents/pt Manual técnico]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/pt FAQ]", + "config-sidebar-readme": "Leia-me", + "config-sidebar-relnotes": "Notas de lançamento", + "config-sidebar-license": "Copiar", + "config-sidebar-upgrade": "Atualizar", "config-env-good": "O ambiente foi verificado.\nPode instalar o MediaWiki.", "config-env-bad": "O ambiente foi verificado.\nNão pode instalar o MediaWiki.", "config-env-php": "O PHP $1 está instalado.", "config-env-hhvm": "HHVM $1 está instalado.", - "config-unicode-using-intl": "A usar a [https://pecl.php.net/intl extensão intl PECL] para a normalização Unicode.", - "config-unicode-pure-php-warning": "Aviso: A [https://pecl.php.net/intl extensão intl PECL] não está disponível para efetuar a normalização Unicode. Irá recorrer-se à implementação em PHP puro, que é mais lenta.\nSe o seu sítio tem alto volume de tráfego, devia informar-se um pouco sobre a [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations/pt normalização Unicode].", + "config-unicode-using-intl": "A usar a [https://php.net/manual/en/book.intl.php extensão intl do PHP] para a normalização Unicode.", + "config-unicode-pure-php-warning": "Aviso: A [https://php.net/manual/en/book.intl.php extensão intl do PHP] não está disponível para efetuar a normalização Unicode. Irá recorrer-se à implementação em PHP puro, que é mais lenta.\nSe o seu sítio tem alto volume de tráfego, devia informar-se sobre a [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations/pt normalização Unicode].", "config-unicode-update-warning": "Aviso: A versão instalada do wrapper de normalização Unicode usa uma versão mais antiga da biblioteca do [http://site.icu-project.org/ projeto ICU].\nDevia [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations atualizá-la] se tem quaisquer preocupações sobre o uso do Unicode.", "config-no-db": "Não foi possível encontrar um controlador apropriado da base de dados! Precisa de instalar um controlador da base de dados para o PHP. {{PLURAL:$2|É aceite o seguinte tipo|São aceites os seguintes tipos}} de base de dados: $1.\n\nSe fez a compilação do PHP, reconfigure-o com um cliente de base de dados ativado; por exemplo, usando ./configure --with-mysqli.\nSe instalou o PHP a partir de um pacote Debian ou Ubuntu, então precisa de instalar também, por exemplo, o pacote php-mysql.", - "config-outdated-sqlite": "Aviso: Tem a versão $1 do SQLite, que é anterior à versão mínima necessária, a $2. O SQLite não estará disponível.", + "config-outdated-sqlite": "Aviso: Tem a versão $2 do SQLite, que é anterior à versão mínima necessária, a $1. O SQLite não estará disponível.", "config-no-fts3": "Aviso: O SQLite foi compilado sem o módulo [//sqlite.org/fts3.html FTS3]; as funcionalidades de pesquisa não estarão disponíveis nesta instalação.", "config-pcre-old": "Erro fatal: É necessário o PCRE $1 ou versão posterior.\nO seu binário PHP foi linkado com o PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Mais informações].", "config-pcre-no-utf8": "'''Erro fatal''': O módulo PCRE do PHP parece ter sido compilado sem suporte PCRE_UTF8.\nO MediaWiki necessita do suporte UTF-8 para funcionar corretamente.", @@ -179,9 +184,6 @@ "config-db-web-no-create-privs": "A conta que especificou para a instalação não tem privilégios suficientes para criar uma conta.\nA conta que especificar aqui já tem de existir.", "config-mysql-engine": "Motor de armazenamento:", "config-mysql-innodb": "InnoDB (recomendado)", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "Aviso: Selecionou o MyISAM para motor de armazenamento do MySQL, uma combinação desaconselhada para usar com o MediaWiki porque:\n* praticamente não permite acessos simultâneos, porque bloqueia tabelas\n* o MyISAM é mais suscetível a perdas da integridade dos dados do que outros motores\n* o código do MediaWiki não trabalha devidamente com o MyISAM\n\nSe a sua instalação do MySQL suporta InnoDB, é altamente recomendado que o escolha em vez do MyISAM.\nSe não suporta o InnoDB, talvez seja uma boa altura para atualizá-la para a versão mais recente.", - "config-mysql-only-myisam-dep": "Aviso: O único motor de armazenamento para MySQL nesta máquina é o MyISAM e o seu uso com o MediaWiki não é recomendado porque:\n* praticamente não suporta acessos simultâneos, porque bloqueia tabelas\n* o MyISAM é mais suscetível a perdas da integridade dos dados do que outros motores\n* o código do MediaWiki não trabalha devidamente com o MyISAM\n\nA sua instalação MySQL não suporta InnoDB, talvez seja uma boa altura para atualizá-la para a versão mais recente.", "config-mysql-engine-help": "InnoDB é quase sempre a melhor opção, porque suporta bem acessos simultâneos (concurrency).\n\nMyISAM pode ser mais rápido no modo de utilizador único ou em instalações somente para leitura.\nAs bases de dados MyISAM tendem a perder integridade de dados com mais frequência do que as bases de dados InnoDB.", "config-mssql-auth": "Tipo de autenticação:", "config-mssql-install-auth": "Selecione o tipo de autenticação a usar para ligar à base de dados durante o processo de instalação.\nSe selecionar \"{{int:config-mssql-windowsauth}}\", serão usadas as credenciais do utilizador com que o servidor de Internet está a ser executado.", diff --git a/includes/installer/i18n/qqq.json b/includes/installer/i18n/qqq.json index 639262e13a..039cd263af 100644 --- a/includes/installer/i18n/qqq.json +++ b/includes/installer/i18n/qqq.json @@ -22,7 +22,8 @@ "Metalhead64", "Tacsipacsi", "Zoranzoki21", - "BadDog" + "BadDog", + "Waldyrious" ] }, "config-desc": "Short description of the installer.", @@ -62,8 +63,12 @@ "config-help-restart": "Message in warning box in MediaWiki installer.", "config-restart": "Button text to confirm the installation procedure has to be restarted.", "config-welcome": "Notice that the installer is about to check as to whether MediaWiki can be installed.", - "config-copyright": "This message follows {{msg-mw|config-env-good}}.\n\nParameters:\n* $1 - copyright and author list", + "config-welcome-section-copyright": "This message follows {{msg-mw|config-env-good}}.\n\nParameters:\n* $1 - copyright and author list\n* $2 - Link to the installer page that contains the license text", "config-sidebar": "Maximum width for words is 24 characters. Only visible part of the translation counts to this limit.", + "config-sidebar-readme": "Link in sidebar to read the README documentation", + "config-sidebar-relnotes": "Link in sidebar to read the RELEASE-NOTES documentation", + "config-sidebar-license": "Link in sidebar to read the COPYING file, containing the full license text", + "config-sidebar-upgrade": "Link in sidebar to read the UPGRADE documentation", "config-env-good": "See also:\n* {{msg-mw|Config-env-bad}}", "config-env-bad": "See also:\n* {{msg-mw|Config-env-good}}", "config-env-php": "Parameters:\n* $1 - the version of PHP that has been installed\nSee also:\n* {{msg-mw|config-env-php-toolow}}", @@ -180,9 +185,6 @@ "config-db-web-no-create-privs": "Error message in the MediaWiki installer.", "config-mysql-engine": "Field label for MySQL storage engine in the MediaWiki installer.", "config-mysql-innodb": "Option for the MySQL storage engine in the MediaWiki installer.", - "config-mysql-myisam": "Option for the MySQL storage engine in the MediaWiki installer.", - "config-mysql-myisam-dep": "Warning message in the MediaWiki installer when MyISAM is chosen as MySQL storage engine.", - "config-mysql-only-myisam-dep": "Used as warning message when mysql does not support the minimum suggested feature set.", "config-mysql-engine-help": "Help text in MediaWiki installer with advice for picking a MySQL storage engine.", "config-mssql-auth": "Radio button group label.\n\nFollowed by the following radio button labels:\n* {{msg-mw|Config-mssql-sqlauth}}\n* {{msg-mw|Config-mssql-windowsauth}}", "config-mssql-install-auth": "Used as the help text for the \"Authentication type\" radio button when typing in database settings for installation.\n\nRefers to {{msg-mw|Config-mssql-windowsauth}}.\n\nSee also:\n* {{msg-mw|Config-mssql-web-auth}}", diff --git a/includes/installer/i18n/ro.json b/includes/installer/i18n/ro.json index 66eca85ffa..fb68b305ad 100644 --- a/includes/installer/i18n/ro.json +++ b/includes/installer/i18n/ro.json @@ -50,7 +50,7 @@ "config-help-restart": "Doriți să ștergeți toate datele salvate introduse și să reporniți procesul de instalare?", "config-restart": "Da, repornește.", "config-welcome": "=== Verificări ale mediului ===\nVerificări de bază vor fi efectuate pentru a vedea dacă este potrivit pentru instalarea MediaWiki.\nNu uitați să includeți aceste informații dacă doriți asistență pentru completarea instalării.", - "config-copyright": "=== Drepturi de autor și termeni ===\n\n$1\n\nAcest program este un software liber; îl puteți redistribui și / sau modifica în conformitate cu termenii Licenței Publice Generale GNU, publicată de Fundația pentru Software Liber; fie versiunea 2 a Licenței, fie (la alegere) orice versiune ulterioară.\nAcest program este distribuit în speranța că va fi util, dar fără nicio garanție; fără nici măcar garanția implicită de vandabilitate sau fitness pentru un anumit scop.\nPentru mai multe detalii, consultați Licența publică generală GNU.\nAr fi trebuit să fi primit o copie a GNU General Public License împreună cu acest program; dacă nu, scrieți la Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, SUA, sau [https://www.gnu.org/copyleft/gpl.html citiți-o online] .", + "config-welcome-section-copyright": "=== Drepturi de autor și termeni ===\n\n$1\n\nAcest program este un software liber; îl puteți redistribui și / sau modifica în conformitate cu termenii Licenței Publice Generale GNU, publicată de Fundația pentru Software Liber; fie versiunea 2 a Licenței, fie (la alegere) orice versiune ulterioară.\nAcest program este distribuit în speranța că va fi util, dar fără nicio garanție; fără nici măcar garanția implicită de vandabilitate sau fitness pentru un anumit scop.\nPentru mai multe detalii, consultați Licența publică generală GNU.\nAr fi trebuit să fi primit [$2 o copie a GNU General Public License] împreună cu acest program; dacă nu, scrieți la Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, SUA, sau [https://www.gnu.org/copyleft/gpl.html citiți-o online] .", "config-sidebar": "* [https://www.mediawiki.org Acasă MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents User's Guide]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Administrator's Guide]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]\n----\n* Read me\n* Release notes\n* Copying\n* Upgrading", "config-env-good": "Verificarea mediului a fost efectuată cu succes.\nPuteți instala MediaWiki.", "config-env-bad": "Verificarea mediului a fost efectuată.\nNu puteți instala MediaWiki.", @@ -115,7 +115,6 @@ "config-db-web-create": "Creați contul dacă nu există deja", "config-mysql-engine": "Motor de stocare:", "config-mysql-innodb": "InnoDB (recomandat)", - "config-mysql-myisam": "MyISAM", "config-mssql-auth": "Tip de autentificare:", "config-site-name": "Numele wikiului:", "config-site-name-blank": "Introduceți un nume pentru sit.", diff --git a/includes/installer/i18n/roa-tara.json b/includes/installer/i18n/roa-tara.json index 6f6e003e1e..df1ca1adc6 100644 --- a/includes/installer/i18n/roa-tara.json +++ b/includes/installer/i18n/roa-tara.json @@ -31,6 +31,11 @@ "config-page-upgradedoc": "Aggiornamende", "config-page-existingwiki": "Uicchi esistende", "config-restart": "Sìne, falle repartì", + "config-sidebar": "* [https://www.mediawiki.org Pàgena Prengepàle MediaUicchi]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Guide de l'utende]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Guide de l'amministratore]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]", + "config-sidebar-readme": "Liggeme", + "config-sidebar-relnotes": "Note de rilasce", + "config-sidebar-license": "Stoche a copie", + "config-sidebar-upgrade": "Aggiornamende", "config-env-php": "PHP $1 ha state installate.", "config-env-hhvm": "HHVM $1 ha state installate.", "config-outdated-sqlite": "Iapre l'uecchjie: tu è SQLite $2, ca jè 'na versione troppe vecchie respette a quedda minime $1. SQLite non g'è disponibbele.", @@ -53,7 +58,6 @@ "config-header-mssql": "'Mbostaziune de Microsoft SQL Server", "config-invalid-db-type": "Tipe de database invalide.", "config-mysql-innodb": "InnoDB (conzigliate)", - "config-mysql-myisam": "MyISAM", "config-ns-generic": "Proggette", "config-admin-email": "Indirizze e-mail:", "config-install-step-done": "fatte", diff --git a/includes/installer/i18n/ru.json b/includes/installer/i18n/ru.json index 8297dd80cf..03ef206bd5 100644 --- a/includes/installer/i18n/ru.json +++ b/includes/installer/i18n/ru.json @@ -28,7 +28,8 @@ "Movses", "Vlad5250", "Athena Atterdag", - "Diralik" + "Diralik", + "Alexander Istomin" ] }, "config-desc": "Инсталлятор MediaWiki", @@ -68,17 +69,21 @@ "config-help-restart": "Вы хотите удалить все сохранённые данные, которые вы ввели, и запустить процесс установки заново?", "config-restart": "Да, начать заново", "config-welcome": "=== Проверка окружения ===\nБудут проведены базовые проверки с целью определить, подходит ли данная система для установки MediaWiki.\nНе забудьте включить эту информацию, если вам потребуется помощь для завершения установки.", - "config-copyright": "=== Авторские права и условия ===\n\n$1\n\nMediaWiki — свободное программное обеспечение, которое вы можете распространять и/или изменять в соответствии с условиями лицензии GNU General Public License, опубликованной фондом свободного программного обеспечения; второй версии, либо любой более поздней версии.\n\nMediaWiki распространяется в надежде, что она будет полезной, но без каких-либо гарантий, даже без подразумеваемых гарантий коммерческой ценности или пригодности для определённой цели. См. лицензию GNU General Public License для более подробной информации.\n\nВы должны были получить копию GNU General Public License вместе с этой программой, если нет, то напишите Free Software Foundation, Inc., по адресу: 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA или [https://www.gnu.org/copyleft/gpl.html прочтите её онлайн].", - "config-sidebar": "* [https://www.mediawiki.org Сайт MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents/ru Справка для пользователей]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents/ru Справка для администраторов]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/ru FAQ]\n----\n* Readme-файл\n* Информация о выпуске\n* Лицензия\n* Обновление", + "config-welcome-section-copyright": "=== Авторские права и условия ===\n\n$1\n\nMediaWiki — свободное программное обеспечение, которое вы можете распространять и/или изменять в соответствии с условиями лицензии GNU General Public License, опубликованной фондом свободного программного обеспечения; второй версии, либо любой более поздней версии.\n\nMediaWiki распространяется в надежде, что она будет полезной, но без каких-либо гарантий, даже без подразумеваемых гарантий коммерческой ценности или пригодности для определённой цели. См. лицензию GNU General Public License для более подробной информации.\n\nВы должны были получить [$2 копию GNU General Public License] вместе с этой программой, если нет, то напишите Free Software Foundation, Inc., по адресу: 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA или [https://www.gnu.org/copyleft/gpl.html прочтите её онлайн].", + "config-sidebar": "* [https://www.mediawiki.org Сайт MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Справка для пользователей]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Справка для администраторов]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]", + "config-sidebar-readme": "Прочти меня", + "config-sidebar-relnotes": "Информация о версии", + "config-sidebar-license": "Копирование", + "config-sidebar-upgrade": "Обновление", "config-env-good": "Проверка внешней среды была успешно проведена.\nВы можете установить MediaWiki.", "config-env-bad": "Была проведена проверка внешней среды.\nВы не можете установить MediaWiki.", "config-env-php": "Установленная версия PHP: $1.", "config-env-hhvm": "HHVM $1 установлена.", - "config-unicode-using-intl": "Будет использовано [https://pecl.php.net/intl расширение «intl» для PECL] для нормализации Юникода.", - "config-unicode-pure-php-warning": "'''Внимание!''': [https://pecl.php.net/intl расширение intl из PECL] недоступно для нормализации Юникода, будет использоваться медленная реализация на чистом PHP.\nЕсли ваш сайт работает под высокой нагрузкой, вам следует больше узнать о [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations нормализации Юникода].", + "config-unicode-using-intl": "Будет использовано [https://php.net/manual/en/book.intl.php PHP intl расширение] для нормализации Юникода.", + "config-unicode-pure-php-warning": "'''Внимание!''': [https://php.net/manual/en/book.intl.php PHP intl расширение] недоступно для нормализации Юникода, будет использоваться медленная реализация на чистом PHP.\nЕсли ваш сайт работает под высокой нагрузкой, вам следует больше узнать о [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations нормализации Юникода].", "config-unicode-update-warning": "'''Предупреждение''': установленная версия обёртки нормализации Юникода использует старую версию библиотеки [http://site.icu-project.org/ проекта ICU].\nВы должны [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations обновить версию], если хотите полноценно использовать Юникод.", "config-no-db": "Не удалось найти подходящие драйвера баз данных! Вам необходимо установить драйвера базы данных для PHP.\n{{PLURAL:$2|Поддерживается следующий тип|Поддерживаются следующие типы}} баз данных: $1.\n\nЕсли вы скомпилировали PHP сами, перенастройте его с включением клиента баз данных, например, с помощью ./configure --with-mysqli.\nЕсли вы установили PHP из пакетов Debian или Ubuntu, то вам также необходимо установить, например, пакет php-mysql.", - "config-outdated-sqlite": "'''Предупреждение''': у Вас установлен SQLite $1, версия которого ниже требуемой $2 . SQLite будет недоступен.", + "config-outdated-sqlite": "Предупреждение: у Вас установлен SQLite $2, версия которого ниже требуемой $1. SQLite будет недоступен.", "config-no-fts3": "'''Внимание''': SQLite собран без модуля [//sqlite.org/fts3.html FTS3] — поиск не будет работать для этой базы данных.", "config-pcre-old": "'''Фатальная ошибка:''' требуется PCRE версии $1 или более поздняя.\nВаш исполняемый файл PHP связан с PCRE версии $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Подробнее].", "config-pcre-no-utf8": "'''Фатальная ошибка'''. Модуль PCRE для PHP, похоже, собран без поддержки PCRE_UTF8.\nMediaWiki требует поддержки UTF-8 для корректной работы.", @@ -186,9 +191,6 @@ "config-db-web-no-create-privs": "Учётная запись, указанная вами для установки, не обладает достаточными правами для создания учётной записи.\nУказанная здесь учётная запись уже должна существовать.", "config-mysql-engine": "Движок базы данных:", "config-mysql-innodb": "InnoDB (рекомендуется)", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "''' Внимание.''' Вы выбрали механизм MyISAM для хранения данных MySQL. Он не рекомендуется к использованию по следующим причинам:\n* он слабо поддерживает параллелизм из-за табличных блокировок;\n* более склонен к потере данных, по сравнению с другими механизмами;\n* код MediaWiki не всегда учитывает особенности MyISAM должным образом.\n\nЕсли ваша MySQL поддерживает InnoDB, настоятельно рекомендуется выбрать этот механизм.\nЕсли ваша MySQL не поддерживает InnoDB, возможно, настало время обновиться.", - "config-mysql-only-myisam-dep": "Предупреждение: MyISAM — единственная доступная система хранения данных для MySQL на этом компьютере, и она не рекомендуется для использования совместно с MediaWiki, потому что:\n* слабо поддерживает параллелизм из-за блокировки таблиц\n* больше других систем подвержена повреждению\n* кодовая база MediaWiki не всегда обрабатывает MyISAM так, как следует\n\nВаша MySQL не поддерживает InnoDB, так что, возможно, настало время для обновления.", "config-mysql-engine-help": "'''InnoDB''' почти всегда предпочтительнее, так как он лучше справляется с параллельным доступом.\n\n'''MyISAM''' может оказаться быстрее для вики с одним пользователем или с минимальным количеством поступающих правок, однако базы данных на нём портятся чаще, чем на InnoDB.", "config-mssql-auth": "Тип аутентификации:", "config-mssql-install-auth": "Выберите тип проверки подлинности, который будет использоваться для подключения к базе данных во время процесса установки.\nЕсли вы выберите «{{int:config-mssql-windowsauth}}», будут использоваться учётные данные пользователя, под которым работает веб-сервер.", diff --git a/includes/installer/i18n/sco.json b/includes/installer/i18n/sco.json index a8bada1dff..3fc52abd98 100644 --- a/includes/installer/i18n/sco.json +++ b/includes/installer/i18n/sco.json @@ -45,7 +45,7 @@ "config-help-restart": "Div ye wish tae clear aw hained data that ye'v entered n restairt the instawlation process?", "config-restart": "Ai, restart it", "config-welcome": "=== Environmêntal checks ===\nBasic checks will nou be performed tae see gif this environment is suitable fer MediaWiki installâtion.\nMynd tae inclæde this information gif ye seek heelp oan hou tae complete the installâtion.", - "config-copyright": "=== Copiericht n Terms ===\n\n$1\n\nThis program is free saffware; ye can redistreebute it n/or modifie it unner the terms o the GNU General Public License aes published bi the Free Software Foundation; either version 2 o the License, or (yer optie) onie later version.\n\nThis program is distributed in the hope that it will be uiseful, but wioot onie warrantie; wioot even the implied warrantie o merchantabeelity or fitness fer ae parteecular purpose.\nSee the GNU General Public License fer mair details.\n\nYe shid hae receeved ae copie o the GNU General Publeec License alang wi this program; gif naw, write til the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, or [https://www.gnu.org/copyleft/gpl.html read it online].", + "config-welcome-section-copyright": "=== Copiericht n Terms ===\n\n$1\n\nThis program is free saffware; ye can redistreebute it n/or modifie it unner the terms o the GNU General Public License aes published bi the Free Software Foundation; either version 2 o the License, or (yer optie) onie later version.\n\nThis program is distributed in the hope that it will be uiseful, but wioot onie warrantie; wioot even the implied warrantie o merchantabeelity or fitness fer ae parteecular purpose.\nSee the GNU General Public License fer mair details.\n\nYe shid hae receeved [$2 ae copie o the GNU General Publeec License] alang wi this program; gif naw, write til the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, or [https://www.gnu.org/copyleft/gpl.html read it online].", "config-sidebar": "* [https://www.mediawiki.org MediaWiki home]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents User's Guide]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Administrator's Guide]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]\n----\n* Read me\n* Release notes\n* Copiein\n* Upgradin", "config-env-good": "The environment haes been checked.\nYe can install MediaWiki.", "config-env-bad": "The environment haes been checked.\nYe canna install MediaWiki.", @@ -157,9 +157,6 @@ "config-db-web-no-create-privs": "The accoont that ye speceefied fer instawation disna hae enooch preevileges tae cræft aen accoont.\nThe accoont that ye speceefie here maun awreadie exeest.", "config-mysql-engine": "Storage engine:", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "Warnishment: Ye'v selected MyISAM aes storage engine fer MySQL, this isna recommended fer uiss wi MediaWiki, cause:\n* it barelie supports concurrencie cause o buird lockin\n* it's mair prone til rot than ither engines\n* the MediaWiki codebase disna aye haunnle MyISAM aes it shid\n\nGif yer MySQL installâtion supports InnoDB, it is heilie recommended that ye chuise that instead.\nGif yer MySQL installâtion disna support InnoDB, than perhaps it's time fer aen upgrade.", - "config-mysql-only-myisam-dep": "Warnishment: MyISAM is the yinly available storage engine fer MySQL oan this machine, n this isna recommended fer uiss wi MediaWiki, cause:\n* it barelie supports concurrencie cause o buird lockin\n* it is mair prone til rot than ither engines\n* the MediaWiki codebase disna aye haunnle MyISAM aes it shid\n\nYer MySQL installâtion dina support InnoDB, perhaps it's time fer aen upgrade.", "config-mysql-engine-help": "InnoDB is awmaist aye the best optie, aes it haes guid concurrencie support.\n\nMyISAM micht be faster in single-uiser or read-yinly installâtions.\nMyISAM databases tend tae rot mair aften than InnoDB databases.", "config-mssql-auth": "Authentication type:", "config-mssql-install-auth": "Select the authentication type that's tae be uised tae connect wi the database durin the installation process.\nGif ye select \"{{int:config-mssql-windowsauth}}\", the credeentials o whitever uiser the wabserver is rinnin aes will be uised.", diff --git a/includes/installer/i18n/sh.json b/includes/installer/i18n/sh.json index e88477050c..c718b75b00 100644 --- a/includes/installer/i18n/sh.json +++ b/includes/installer/i18n/sh.json @@ -44,8 +44,12 @@ "config-help-restart": "Želite li obrisati sve sačuvane podatke koje ste unijeli i ponovo pokrenuti uspostavu?", "config-restart": "Da, pokreni ponovo", "config-welcome": "=== Provjere okoline ===\nSada ćemo obaviti osnovne provjere kako bismo utvrdili je li okruženje prikladno za uspostavu MediaWikija. Ne zaboravite navesti ove informacije ako tražite pomoć s dovršavanjem uspostave.", - "config-copyright": "=== Autorska prava i uvjeti ===\n\n$1\n\nTo je slobodni softver (free software); možete ga redistribuirati i/ili mijenjati pod uvjetima GNU-ove opće javne licence (GNU General Public License) Zaklade za slobodni softver (Free Software Foundation); verzija 2 ili bilo koja kasnija verzija licence (po vašem izboru).\n\nOvaj se program nudi u nadi da će biti koristan, ali '''bez ikakvog jamstva'''; čak i implicirano jamstvo '''sposobnosti prodaje''' ili '''prikladnosti za određenu svrhu'''.\nViše informacija ćete naći u tekstu GNU-ove opće javne licence.\n\nTrebali ste primiti primjerak GNU-ove opće javne licence zajedno s programima; ako ga ne primite, pišite nam na Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. ili [https://www.gnu.org/copyleft/gpl.html pročitajte je ovdje].", - "config-sidebar": "* [https://www.mediawiki.org Početna strana MediaWikija]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Vodič za korisnike]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Vodič za administratore]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ ČPP]\n----\n* Pročitaj me\n* Bilješke o izdanju\n* Kopiranje\n* Nadograđivanje", + "config-welcome-section-copyright": "=== Autorska prava i uvjeti ===\n\n$1\n\nTo je slobodni softver (free software); možete ga redistribuirati i/ili mijenjati pod uvjetima GNU-ove opće javne licence (GNU General Public License) Zaklade za slobodni softver (Free Software Foundation); verzija 2 ili bilo koja kasnija verzija licence (po vašem izboru).\n\nOvaj se program nudi u nadi da će biti koristan, ali '''bez ikakvog jamstva'''; čak i implicirano jamstvo '''sposobnosti prodaje''' ili '''prikladnosti za određenu svrhu'''.\nViše informacija ćete naći u tekstu GNU-ove opće javne licence.\n\nTrebali ste primiti [$2 primjerak GNU-ove opće javne licence] zajedno s programima; ako ga ne primite, pišite nam na Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. ili [https://www.gnu.org/copyleft/gpl.html pročitajte je ovdje].", + "config-sidebar": "* [https://www.mediawiki.org Početna strana MediaWikija]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Vodič za korisnike]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Vodič za administratore]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ ČPP]", + "config-sidebar-readme": "Pročitaj me", + "config-sidebar-relnotes": "Bilješke o izdanju", + "config-sidebar-license": "Kopiranje", + "config-sidebar-upgrade": "Nadogradnja", "config-env-good": "Okruženje je provjereno.\nMožete uspostaviti MediaWiki.", "config-env-bad": "Okruženje je provjereno.\nNe možete uspostaviti MediaWiki.", "config-env-php": "PHP $1 je uspostavljen.", @@ -159,9 +163,11 @@ "config-db-web-no-create-privs": "Račun koji ste naveli za uspostavu nema dovoljne privilegije za da stvori račun.\nOvdje morate navesti postojeći račun.", "config-mysql-engine": "Skladišni pogon:", "config-mysql-innodb": "InnoDB (preporučeno)", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "Upozorenje: Odabrali ste MyISAM kao skladišni pogon za MySQL. Ali ne preporučuje se za MediaWiki jer:\n* jedva podržava istovremenost iz zaključavanja tabela\n* vjerojatnije je da će ih druge biljke pokvariti\n* kodna baza MediaWikija ne može uvijek ispravno raditi s MyISAM-om\n\nAko vaša uspostava MySQL-a podržava InnoDB, tada seriozno preporučujemo da je koristite umjesto MyISAM.\nAko vaša uspostava MySQL-a ne podržava InnoDB, vjerojatno je vrijeme za nadogradnju.", - "config-mysql-only-myisam-dep": "Upozorenje: MyISAM je jedini dostupan skladišni pogon za MySQL na ovom stroju, a ovo se ne preporučuje za uporabu s MediaWiki, jer:\n* skoro ne podržava istovremeno izvršavanje zadataka zbog zaključavanja tablica\n* više osjetljiv na kvarenje od drugih pogona \n* kodna baza MediaWIkija ne radi uvijek ispravno s MyISAM-om\nVaša MySQL uspostava ne podržava InnoDB. Možda je vrijeme da ga nadogradimo.", + "config-mssql-auth": "Tip potvrde identiteta:", + "config-mssql-sqlauth": "Potvrda identiteta za SQL Server", + "config-mssql-windowsauth": "Potvrda identiteta za Windows", + "config-site-name": "Ime wikija:", + "config-site-name-help": "Ovo će se pojaviti u naslovnoj traci pregledača i na raznim drugim mestima.", "config-admin-password": "Lozinka:", "mainpagetext": "MediaWiki je uspješno instaliran.", "mainpagedocfooter": "Za informacije o korištenju wiki softvera konzultirajte [https://meta.wikimedia.org/wiki/Help:Contents Vodič za korisnike].\n\n== Uvod u rad ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista konfiguracije postavki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Lista primatelja izdanja MediaWikija]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Lokalizirajte MediaWiki za svoj jezik]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Saznajte kako se boriti protiv spama na svojem wikiju]" diff --git a/includes/installer/i18n/si.json b/includes/installer/i18n/si.json index 363014c7ac..fefe7a21d2 100644 --- a/includes/installer/i18n/si.json +++ b/includes/installer/i18n/si.json @@ -62,7 +62,6 @@ "config-db-web-account": "ජාල ප්‍රවේශනය සඳහා දත්ත සංචිත ගිණුම", "config-mysql-engine": "ආචයන එන්ජිම:", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", "config-mssql-windowsauth": "windows සහතික කිරීම.", "config-site-name": "විකියෙහි නම:", "config-site-name-blank": "අඩවි නාමයක් යොදන්න.", diff --git a/includes/installer/i18n/sl.json b/includes/installer/i18n/sl.json index 5471fdb48a..3dd70380a8 100644 --- a/includes/installer/i18n/sl.json +++ b/includes/installer/i18n/sl.json @@ -45,12 +45,12 @@ "config-help-restart": "Želite počistiti vse shranjene podatke, ki ste jih vnesti, in ponovno začeti s postopkom namestitve?", "config-restart": "Da, ponovno zaženi", "config-welcome": "=== Pregledi okolja ===\nIzvedli bomo osnovne preglede, da vidimo, če je okolje primerno za namestitev MediaWiki.\nPosredujte rezultate teh pregledov, če med namestitvijo potrebujete pomoč.", - "config-sidebar": "* [https://www.mediawiki.org Domača stran MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Vodnik za uporabnike]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Vodnik za administratorje]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Pogosto zastavljena vprašanja]\n----\n* Beri me\n* Opombe ob izidu\n* Kopiranje\n* Nadgrajevanje", + "config-sidebar": "* [https://www.mediawiki.org Domača stran MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Vodnik za uporabnike]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Vodnik za administratorje]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Pogosto zastavljena vprašanja]", "config-env-good": "Okolje je pregledano.\nLahko namestite MediaWiki.", "config-env-bad": "Okolje je pregledano.\nNe morete namestiti MediaWiki.", "config-env-php": "Nameščen je PHP $1.", "config-env-hhvm": "HHVM $1 je nameščen.", - "config-unicode-using-intl": "Uporaba [https://pecl.php.net/intl razširitve PECL intl] za normalizacijo unikoda.", + "config-unicode-using-intl": "Uporaba [https://php.net/manual/en/book.intl.php PHP-razširitve intl] za normalizacijo unikoda.", "config-memory-raised": "PHP-jev memory_limit je $1, dvignjen na $2.", "config-apc": "[https://www.php.net/apc APC] je nameščen", "config-wincache": "[https://www.iis.net/downloads/microsoft/wincache-extension WinCache] je nameščen", @@ -105,7 +105,6 @@ "config-db-web-create": "Ustvari račun, če že ne obstaja", "config-mysql-engine": "Pogon skladiščenja:", "config-mysql-innodb": "InnoDB (priporočeno)", - "config-mysql-myisam": "MyISAM", "config-mssql-auth": "Tip avtentikacije:", "config-site-name": "Ime wikija:", "config-site-name-help": "To bo prikazano v naslovni vrstici brskalnika in na drugih različnih mestih.", diff --git a/includes/installer/i18n/sr-ec.json b/includes/installer/i18n/sr-ec.json index 3ba7928dab..e740511c1f 100644 --- a/includes/installer/i18n/sr-ec.json +++ b/includes/installer/i18n/sr-ec.json @@ -55,8 +55,8 @@ "config-env-bad": "Окружење је проверено.\nНе можете да инсталирате MediaWiki.", "config-env-php": "PHP $1 је инсталиран.", "config-env-hhvm": "HHVM $1 је инсталиран.", - "config-unicode-using-intl": "Користи се [https://pecl.php.net/intl додатак intl PECL] за нормализацију Уникода.", - "config-outdated-sqlite": "Упозорење: имате SQLite $1, који је нижи од најмање тражене верзије ($2). SQLite ће бити недоступан.", + "config-unicode-using-intl": "Користи се [https://php.net/manual/en/book.intl.php додатак PHP intl] за нормализацију Уникода.", + "config-outdated-sqlite": "Упозорење: имате SQLite $2, који је нижи од најмање тражене верзије $1. SQLite ће бити недоступан.", "config-no-fts3": "Упозорење: SQLite је компајлиран без [//sqlite.org/fts3.html FTS3 модула], функције претраге биће недоступне на овој бази података.", "config-pcre-old": "Неотклоњива грешка: Неопходан је PCRE $1 или новији.\nВаш бинарни PHP је повезан са PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Више информација].", "config-pcre-no-utf8": "Неотклоњива грешка: Изгледа да је PCRE модул PHP-а компајлиран без PCRE_UTF8 подршке.\nMediaWiki захтева UTF-8 подршку за исправно функционисање.", @@ -152,7 +152,6 @@ "config-db-web-no-create-privs": "Налог који сте навели за инсталацију нема довољне привилегије да отвори налог.\nНалог који овде наведете већ мора да постоји.", "config-mysql-engine": "Механизам складишта:", "config-mysql-innodb": "InnoDB (препоручено)", - "config-mysql-myisam": "MyISAM", "config-mssql-auth": "Тип потврде идентитета:", "config-mssql-sqlauth": "SQL Server потврда идентитета", "config-mssql-windowsauth": "Windows потврда идентитета", @@ -277,6 +276,7 @@ "config-install-done": "Честитамо!\nИнсталирали сте MediaWiki.\n\nИнсталациони програм је генерисао датотеку LocalSettings.php.\nОна садржи сву вашу конфигурацију.\n\nМораћете да је преузмете и ставите у базу ваше вики инсталације (исти директоријум као index.php). Преузимање би аутоматски требало почети.\n\nАко преузимање није понуђено, или ако га откажете, можете поново покренути преузимање тако што ћете кликнути на доленаведену везу:\n\n$3\n\nНапомена: Ако то одмах не урадите, ова генерисана конфигурациона датотека неће вам бити доступна касније ако изађете из инсталације без преузимања.\n\nКада је то учињено, можете да [$2 посетите свој вики].", "config-install-done-path": "Честитамо!\nИнсталирали сте MediaWiki.\n\nИнсталациони програм је генерисао датотеку LocalSettings.php.\nОна садржи сву вашу конфигурацију.\n\nМораћете да је преузмете и ставите у $4. Преузимање би аутоматски требало почети.\n\nАко преузимање није понуђено, или ако га откажете, можете поново покренути преузимање тако што ћете кликнути на доленаведену везу:\n\n$3\n\nНапомена: Ако то одмах не урадите, ова генерисана конфигурациона датотека неће вам бити доступна касније ако изађете из инсталације без преузимања.\n\nКада је то учињено, можете да [$2 посетите свој вики].", "config-install-success": "MediaWiki је успешно инсталиран. Сада можете посетити <$1$2> да бисте видели свој вики.\nАко имате питања, погледајте нашу листу често постављаних питања: или користите један од форума за подршку који су повезани на тој страници.", + "config-install-db-success": "База података је успешно подешена", "config-download-localsettings": "Преузми датотеку LocalSettings.php", "config-help": "помоћ", "config-help-tooltip": "кликните да бисте проширили", diff --git a/includes/installer/i18n/sr-el.json b/includes/installer/i18n/sr-el.json index 0b31bca6d5..26d60f536a 100644 --- a/includes/installer/i18n/sr-el.json +++ b/includes/installer/i18n/sr-el.json @@ -146,7 +146,6 @@ "config-db-web-no-create-privs": "Nalog koji ste naveli za instalaciju nema dovoljne privilegije da otvori nalog.\nNalog koji ovde navedete već mora da postoji.", "config-mysql-engine": "Mehanizam skladišta:", "config-mysql-innodb": "InnoDB (preporučeno)", - "config-mysql-myisam": "MyISAM", "config-mssql-auth": "Tip potvrde identiteta:", "config-mssql-sqlauth": "SQL Server potvrda identiteta", "config-mssql-windowsauth": "Windows potvrda identiteta", diff --git a/includes/installer/i18n/sv.json b/includes/installer/i18n/sv.json index e2cb99e479..45b85457ca 100644 --- a/includes/installer/i18n/sv.json +++ b/includes/installer/i18n/sv.json @@ -49,17 +49,21 @@ "config-help-restart": "Vill du rensa all sparad data som du har angivit och starta om installationen?", "config-restart": "Ja, starta om", "config-welcome": "=== Miljökontroller ===\nGrundläggande kontroller kommer nu att utföras för att se om denna miljö är lämplig för installation av MediaWiki.\nKom ihåg att ta med denna information om du söker stöd för hur du skall slutföra installationen.", - "config-copyright": "=== Upphovsrätt och Villkor ===\n\n$1\n\nDetta program är fri programvara; du kan vidaredistribuera den och/eller modifiera det enligt villkoren i GNU General Public License som publicerats av Free Software Foundation; antingen genom version 2 av licensen, eller (på ditt initiativ) någon senare version.\n\nDetta program är distribuerat i hopp om att det kommer att vara användbart, men '''utan någon garanti'''; utan att ens ha en underförstådd garanti om '''säljbarhet''' eller '''lämplighet för ett särskilt ändamål'''.\nSe GNU General Public License för mer detaljer.\n\nDu bör ha fått en kopia av GNU General Public License tillsammans med detta program; om inte, skriv till Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, eller [https://www.gnu.org/copyleft/gpl.html läs den online].", - "config-sidebar": "* [https://www.mediawiki.org MediaWikis webbplats]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Användarguide]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Administratörguide]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Frågor och svar]\n----\n* Läs mig\n* Utgivningsanteckningar\n* Kopiering\n* Uppgradering", + "config-welcome-section-copyright": "=== Upphovsrätt och Villkor ===\n\n$1\n\nDetta program är fri programvara; du kan vidaredistribuera den och/eller modifiera det enligt villkoren i GNU General Public License som publicerats av Free Software Foundation; antingen genom version 2 av licensen, eller (på ditt initiativ) någon senare version.\n\nDetta program är distribuerat i hopp om att det kommer att vara användbart, men '''utan någon garanti'''; utan att ens ha en underförstådd garanti om '''säljbarhet''' eller '''lämplighet för ett särskilt ändamål'''.\nSe GNU General Public License för mer detaljer.\n\nDu bör ha fått [$2 en kopia av GNU General Public License] tillsammans med detta program; om inte, skriv till Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, eller [https://www.gnu.org/copyleft/gpl.html läs den online].", + "config-sidebar": "* [https://www.mediawiki.org MediaWikis webbplats]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Användarguide]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Administratörsguide]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Frågor och svar]", + "config-sidebar-readme": "Läs mig", + "config-sidebar-relnotes": "Utgivningsanteckningar", + "config-sidebar-license": "Kopierar", + "config-sidebar-upgrade": "Uppgradering", "config-env-good": "Miljön har kontrollerats.\nDu kan installera MediaWiki.", "config-env-bad": "Miljön har kontrollerats.\nDu kan inte installera MediaWiki.", "config-env-php": "PHP $1 är installerat.", "config-env-hhvm": "HHVM $1 är installerat.", - "config-unicode-using-intl": "Använder [https://pecl.php.net/intl intl PECL-tillägget] för Unicode-normalisering.", - "config-unicode-pure-php-warning": "'''Varning:''' [https://pecl.php.net/intl intl PECL-tillägget] är inte tillgängligt för att hantera Unicode-normalisering, faller tillbaka till en långsamt implementering i ren PHP.\nOm du driver en högtrafikerad webbplats bör du läsa lite om [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode-normalisering].", + "config-unicode-using-intl": "Använder tillägget [https://pecl.php.net/intl PHP intl] för Unicode-normalisering.", + "config-unicode-pure-php-warning": "'''Varning:''' Tillägget [https://php.net/manual/en/book.intl.php PHP intl] är inte tillgängligt för att hantera Unicode-normalisering, faller tillbaka till en långsamt implementering i ren PHP.\nOm du driver en högtrafikerad webbplats bör du läsa lite om [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode-normalisering].", "config-unicode-update-warning": "Varning: Den installerade versionen av Unicode-normaliserings \"wrappern\" använder en äldre version av [http://site.icu-project.org/ ICU projektets] bibliotek.\nDu bör [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations uppgradera] om är intresserad av att använda Unicode.", "config-no-db": "Kunde inte hitta en lämplig databasdrivrutin! Du måste installera en databasdrivrutin för PHP.\nFöljande databas{{PLURAL:$2|typ |typer}} stöds: $1.\n\nI du själv kompilerat din PHP, konfigurera den med en databasklient aktiverad genom att t.ex. använda ./configure --with-mysqli.\nOm du installerade PHP från ett Debian- eller Ubuntupaket måste du även installera, t.ex. php-mysql-paketet.", - "config-outdated-sqlite": "'''Varning:''' du har SQLite $1, vilket är lägre än minimikravet version $2. SQLite kommer inte att vara tillgänglig.", + "config-outdated-sqlite": "Varning: Du har SQLite $2, vilket är lägre än minimikravet version $1. SQLite kommer inte att vara tillgänglig.", "config-no-fts3": "'''Varning:''' SQLite kompileras utan [//sqlite.org/fts3.html FTS3-modulen], sökfunktioner kommer att vara otillgängliga på denna backend.", "config-pcre-old": "'''Kritiskt:''' PCRE $1 eller senare krävs.\nDin PHP-binär är länkad till PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Mer information].", "config-pcre-no-utf8": "'''Kritiskt:''' PHP:s PCRE-modul verkar vara kompilerat utan PCRE_UTF8-stöd.\nMediaWiki kräver stöd för UTF-8 för att fungera korrekt.", @@ -164,9 +168,6 @@ "config-db-web-no-create-privs": "Det konto som du har angett för installation har inte tillräcklig behörighet för att skapa ett konto.\nDet konto du anger här måste redan finnas.", "config-mysql-engine": "Lagringsmotor:", "config-mysql-innodb": "InnoDB (rekommenderas)", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "'''Varning:''' Du har valt MyISAM som lagringsmotor för MySQL, vilket inte rekommenderas för användning med MediaWiki eftersom:\n* den knappt stöder samtidigt exekvering på grund av låsning av tabeller\n* den är mer benägen att korrumpera data än andra motorer\n* MediaWiki-kodbasen hanterar inte alltid MyISAM som den ska\n\nOm din MySQL-installation stöder InnoDB, är det starkt rekommenderat att du väljer det istället.\nOm din MySQL-installation inte stöder InnoDB, kanske det är dags för en uppgradering.", - "config-mysql-only-myisam-dep": "'''Varning:''' MyISAM är den enda tillgängliga lagringsmotorn för MySQL på denna maskin, och den är inte rekommenderad att använda med MediaWiki eftersom:\n* den knappt stöder samtidigt exekvering på grund av låsning av tabeller\n* den är mer benägen att korrumpera data än andra motorer\n* MediaWiki-kodbasen hanterar inte alltid MyISAM som den ska\n\nDin MySQL-installation stöder inte InnoDB, det kanske är dags för en uppgradering.", "config-mysql-engine-help": "'''InnoDB''' är nästan alltid det bästa valet eftersom den har ett bra system för samtidiga arbeten.\n\n'''MyISAM''' kan vara snabbare i enanvändarläge eller skrivskyddade installationer.\nMyISAM-databaser tenderar att bli korrupta oftare än InnoDB-databaser.", "config-mssql-auth": "Autentiseringstyp:", "config-mssql-install-auth": "Välj autentiseringstypen som kommer att användas för att ansluta till databasen under installationsprocessen.\nOm du väljer \"{{int:config-mssql-windowsauth}}\", kommer autentiseringsuppgifterna för den användare webbservern körs som att användas.", @@ -224,7 +225,7 @@ "config-license-help": "Många publika wikis släpper alla bidrag under en [https://freedomdefined.org/Definition fri licens].\nDetta bidrar till en känsla av gemensamt ägandeskap och uppmuntrar till långsiktiga bidrag.\nDet är i allmänhet inte nödvändigt för en privat eller företagswiki.\n\nOm du vill kunna använda text från Wikipedia, och du vill att Wikipedia ska kunna acceptera text kopierad ifrån din wiki bör du välja {{int:config-license-cc-by-sa}}.\n\nWikipedia använde tidigare GNU Free Documentation License.\nGFDL är en giltig licens, men svår att förstå.\nDet är även svårt att återanvända innehåll som licensierats under GFDL.", "config-email-settings": "E-postinställningar", "config-enable-email": "Aktivera utgående e-post", - "config-enable-email-help": "Om du vill att e-post ska fungera behöver,[Config-dbsupport-oracle/manual/en/mail.configuration.php PHPs e-postinställningar] vara konfigurerad på rätt sätt.\nOm du inte vill ha några e-postfunktioner, kan du inaktivera dem här.", + "config-enable-email-help": "Om du vill att e-post ska fungera behöver [https://www.php.net/manual/en/mail.configuration.php PHPs e-postinställningar] vara konfigurerad på rätt sätt.\nOm du inte vill ha några e-postfunktioner kan du inaktivera dem här.", "config-email-user": "Aktivera e-post mellan användare", "config-email-user-help": "Tillåta alla användare att skicka e-post till varandra om de har aktiverat det i sina inställningar.", "config-email-usertalk": "Aktivera meddelanden för användardiskussionssidor", diff --git a/includes/installer/i18n/te.json b/includes/installer/i18n/te.json index 1460fecef5..e8e9a75815 100644 --- a/includes/installer/i18n/te.json +++ b/includes/installer/i18n/te.json @@ -44,7 +44,7 @@ "config-help-restart": "మీరు భద్రపరిచిన డేటా మొత్తాన్ని తీసివేసి స్థాపనను తిరిగి ప్రారంభించాలా?", "config-restart": "ఔను, తిరిగి ప్రారంభించు", "config-welcome": "=== పర్యావరణ పరీక్షలు ===\nఈ పర్యావరణం MediaWiki స్థాపనకు అనుకూలంగా ఉందో లేదో చూసే ప్రాథమిక పరీక్షలు ఇపుడు చేస్తాం.\nస్థాపనను ఎలా పూర్తి చెయ్యాలనే విషయమై మీకు సహాయం అడిగేటపుడు, ఈ సమాచారాన్ని ఇవ్వాలని గుర్తుంచుకోండి.", - "config-copyright": "=== కాపీహక్కు, నిబంధనలు===\n\n$1\n\nఇది ఉచిత సాఫ్ట్‌వేరు; ఫ్రీ సాఫ్ట్‌వేర్ ఫౌండేషన్ వారు ప్రచురించిన GNU జనరల్ పబ్లిక్ లైసెన్సును (2వ లేదా తరువాతి వర్షన్) అనుసరించి దీన్ని పంపిణీ చెయ్యవచ్చు లేదా మార్చుకోనూవచ్చు.\n\nదీని వలన ఉపయోగం ఉంటుందనే నమ్మకంతో ప్రచురింపబడింది. కానీ ఎటువంటి వారంటీ లేదు; వర్తకం చేయదగ్గ లేదా ఒక అవసరానికి సరిపడే సామర్థ్యం ఉన్నదనే అంతరార్థ వారంటీ కూడా లేదు.\nమరిన్ని వివరాలకు GNU జనరల్ పబ్లిక్ లైసెన్స్ చూడండి.\n\nమీరు ఈ ప్రోగ్రాముతో పాటు GNU జనరల్ పబ్లిక్ లైసెన్స్ ప్రతిని అందుకుని ఉండాలి; లేకపోతే, Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA కు జాబు రాయండి లేదా [https://www.gnu.org/copyleft/gpl.html ఆన్‌లైన్‌లో చదివండి].", + "config-welcome-section-copyright": "=== కాపీహక్కు, నిబంధనలు===\n\n$1\n\nఇది ఉచిత సాఫ్ట్‌వేరు; ఫ్రీ సాఫ్ట్‌వేర్ ఫౌండేషన్ వారు ప్రచురించిన GNU జనరల్ పబ్లిక్ లైసెన్సును (2వ లేదా తరువాతి వర్షన్) అనుసరించి దీన్ని పంపిణీ చెయ్యవచ్చు లేదా మార్చుకోనూవచ్చు.\n\nదీని వలన ఉపయోగం ఉంటుందనే నమ్మకంతో ప్రచురింపబడింది. కానీ ఎటువంటి వారంటీ లేదు; వర్తకం చేయదగ్గ లేదా ఒక అవసరానికి సరిపడే సామర్థ్యం ఉన్నదనే అంతరార్థ వారంటీ కూడా లేదు.\nమరిన్ని వివరాలకు GNU జనరల్ పబ్లిక్ లైసెన్స్ చూడండి.\n\nమీరు ఈ ప్రోగ్రాముతో పాటు [$2 GNU జనరల్ పబ్లిక్ లైసెన్స్ ప్రతిని ] అందుకుని ఉండాలి; లేకపోతే, Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA కు జాబు రాయండి లేదా [https://www.gnu.org/copyleft/gpl.html ఆన్‌లైన్‌లో చదివండి].", "config-sidebar": "* [https://www.mediawiki.org MediaWiki మొదటిపేజీ]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents వాడుకరుల మార్గదర్శి]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents అధికారుల మార్గదర్శి]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]\n----\n* చదవాల్సినవి\n* విడుదల గమనికలు\n* కాపీ చెయ్యడం\n* ఉన్నతీకరించడం", "config-env-good": "పర్యావరణాన్ని పరీక్షించాం.\nఇక మీరు MediaWiki ని స్థాపించుకోవచ్చు.", "config-env-bad": "పర్యావరణాన్ని పరీక్షించాం.\nమీరు MediaWiki ని స్థాపించలేరు.", @@ -124,7 +124,6 @@ "config-db-web-no-create-privs": "స్థాపన కోసం మీరిచ్చిన ఖాతాకు ఓ కొత్త ఖాతాను సృష్టించే అనుమతులు లేవు.\nఇక్కడ మీరిచ్చే ఖాతా తప్పనిసరిగా ఈసరికే ఉనికిలో ఉండాలి.", "config-mysql-engine": "స్టోరేజీ ఇంజను:", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", "config-mssql-auth": "ఆథెంటికేషన్ రకం:", "config-mssql-sqlauth": "SQL Server ఆథెంటికేషన్", "config-mssql-windowsauth": "విండోస్ ఆథెంటికేషన్", diff --git a/includes/installer/i18n/th.json b/includes/installer/i18n/th.json index a4e2ac081b..38aafbea5a 100644 --- a/includes/installer/i18n/th.json +++ b/includes/installer/i18n/th.json @@ -47,7 +47,7 @@ "config-help-restart": "คุณต้องการล้างข้อมูลทั้งหมดที่คุณกรอกและเริ่มกระบวนการติดตั้งใหม่อีกครั้งหรือไม่?", "config-restart": "ใช่ เริ่มใหม่อีกครั้ง", "config-welcome": "=== การตรวจสอบสภาพแวดล้อม ===\nการตรวจสอบเบื้องต้นจะกระทำขึ้น เพื่อยืนยันว่าสภาพแวดล้อมปัจจุบันเหมาะสมสำหรับการติดตั้ง MediaWiki หรือไม่\nโปรดจำไว้ว่าให้รวบรวมผลลัพธ์การตรวจสอบนี้ ถ้าคุณต้องการแสวงหาการสนับสนุนเพื่อที่จะติดตั้งให้สมบูรณ์", - "config-copyright": "=== ลิขสิทธิ์และเงื่อนไข ===\n\n$1\n\nโปรแกรมนี้เป็นซอฟต์แวร์เสรี คุณสามารถนำโปรแกรมนี้มาเผยแพร่ซ้ำและ/หรือดัดแปลงได้ภายใต้เงื่อนไขของสัญญาอนุญาตสาธารณะทั่วไปของ GNU (GNU General Public License) ซึ่งเผยแพร่โดย Free Software Foundation (สัญญาอนุญาตรุ่น 2 ขึ้นไป)\n\nโปรแกรมนี้ถูกเผยแพร่โดยหวังว่าจะเป็นประโยชน์แก่ผู้ใช้ แต่จะไม่มีการรับประกันใด ๆ แม้แต่การรับประกันเกี่ยวกับการนำไปใช้ในการซื้อขาย หรือความเหมาะสมสำหรับวัตถุประสงค์เฉพาะ\nสำหรับรายละเอียดเพิ่มเติม โปรดดูที่สัญญาอนุญาตสาธารณะทั่วไปของ GNU\n\nคุณควรได้รับสำเนาของสัญญาอนุญาตสาธารณะทั่วไปของ GNU มาพร้อมกับโปรแกรมนี้ ถ้าไม่ได้รับ ให้ขอได้ที่ Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, \nหรือ[https://www.gnu.org/copyleft/gpl.html อ่านออนไลน์ที่นี่]", + "config-welcome-section-copyright": "=== ลิขสิทธิ์และเงื่อนไข ===\n\n$1\n\nโปรแกรมนี้เป็นซอฟต์แวร์เสรี คุณสามารถนำโปรแกรมนี้มาเผยแพร่ซ้ำและ/หรือดัดแปลงได้ภายใต้เงื่อนไขของสัญญาอนุญาตสาธารณะทั่วไปของ GNU (GNU General Public License) ซึ่งเผยแพร่โดย Free Software Foundation (สัญญาอนุญาตรุ่น 2 ขึ้นไป)\n\nโปรแกรมนี้ถูกเผยแพร่โดยหวังว่าจะเป็นประโยชน์แก่ผู้ใช้ แต่จะไม่มีการรับประกันใด ๆ แม้แต่การรับประกันเกี่ยวกับการนำไปใช้ในการซื้อขาย หรือความเหมาะสมสำหรับวัตถุประสงค์เฉพาะ\nสำหรับรายละเอียดเพิ่มเติม โปรดดูที่สัญญาอนุญาตสาธารณะทั่วไปของ GNU\n\nคุณควรได้รับ[$2 สำเนาของสัญญาอนุญาตสาธารณะทั่วไปของ GNU] มาพร้อมกับโปรแกรมนี้ ถ้าไม่ได้รับ ให้ขอได้ที่ Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, \nหรือ[https://www.gnu.org/copyleft/gpl.html อ่านออนไลน์ที่นี่]", "config-sidebar": "* [https://www.mediawiki.org โฮมเพจของ MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents แนวปฏิบัติของผู้ใช้]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents แนวปฏิบัติของผู้ดูแลระบบ]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ คำถามที่ถามบ่อย]\n----\n* อ่านเอกสารกำกับ\n* บันทึกการเผยแพร่\n* การคัดลอก\n* การอัปเกรด", "config-env-good": "ตรวจสอบสภาพแวดล้อมแล้ว\nคุณสามารถติดตั้ง MediaWiki", "config-env-bad": "ตรวจสอบสภาพแวดล้อมแล้ว\nคุณไม่สามารถติดตั้ง MediaWiki", @@ -159,9 +159,6 @@ "config-db-web-no-create-privs": "บัญชีที่คุณระบุไว้สำหรับการติดตั้งมีสิทธิ์ไม่เพียงพอที่จะสร้างบัญชี\nบัญชีที่คุณระบุไว้ที่นี่จะต้องมีอยู่แล้ว", "config-mysql-engine": "กลไกที่จัดเก็บข้อมูล:", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "คำเตือน: คุณได้เลือก MyISAM เป็นกลไกที่จัดเก็บข้อมูลสำหรับ MySQL ซึ่่งไม่แนะนำให้ใช้กับ MediaWiki เนื่องจาก:\n* ไม่ค่อยสนับสนุนกระบวนการทำงานพร้อมกันเนื่องจากการล็อกตารางข้อมูล\n* มีแนวโน้มที่จะเสียหายมากกว่ากลไกอื่น\n* Codebase ของ MediaWiki ไม่สามารถจัดการ MyISAM ได้ดีเท่าที่ควร\n\nถ้าการติดตั้ง MySQL ของคุณสนับสนุน InnoDB แนะนำอย่างยิ่งว่าให้คุณเลือก InnoDB แทน\nถ้าการติดตั้ง MySQL ของคุณไม่สนับสนุน InnoDB อาจถึงเวลาที่คุณต้องอัปเกรดแล้ว", - "config-mysql-only-myisam-dep": "คำเตือน: กลไกที่จัดเก็บข้อมูลสำหรับ MySQL ที่พร้อมใช้งานบนเครื่องนี้มีเพียง MyISAM ซึ่่งไม่แนะนำให้ใช้กับ MediaWiki เนื่องจาก:\n* ไม่ค่อยสนับสนุนกระบวนการทำงานพร้อมกันเนื่องจากการล็อกตารางข้อมูล\n* มีแนวโน้มที่จะเสียหายมากกว่ากลไกอื่น\n* Codebase ของ MediaWiki ไม่สามารถจัดการ MyISAM ได้ดีเท่าที่ควร\n\nการติดตั้ง MySQL ของคุณไม่สนับสนุน InnoDB อาจถึงเวลาที่คุณต้องอัปเกรดแล้ว", "config-mysql-engine-help": "InnoDB เป็นตัวเลือกที่เกือบดีที่สุดเสมอ เนื่องจากมีการสนับสนุนกระบวนการทำงานพร้อมกัน\n\nMyISAM อาจทำงานได้เร็วกว่าในการติดตั้งแบบผู้ใช้คนเดียวหรือแบบอ่านอย่างเดียว\nฐานข้อมูล MyISAM มักจะได้รับความเสียหายบ่อยมากกว่าฐานข้อมูล InnoDB", "config-mssql-auth": "ชนิดการยืนยันตัวตน:", "config-mssql-install-auth": "เลือกชนิดการยืนยันตัวตนที่จะใช้เชื่อมต่อไปยังฐานข้อมูลในระหว่างกระบวนการติดตั้ง\nถ้าคุณเลือก \"{{int:config-mssql-windowsauth}}\" ข้อมูลประจำตัวที่ระบุว่าเว็บเซิร์ฟเวอร์กำลังทำงานในฐานะผู้ใช้ใดจะถูกใช้", diff --git a/includes/installer/i18n/tl.json b/includes/installer/i18n/tl.json index fe53fa17bc..6d63409992 100644 --- a/includes/installer/i18n/tl.json +++ b/includes/installer/i18n/tl.json @@ -48,7 +48,7 @@ "config-help-restart": "Nais mo bang hawiin ang lahat ng nasagip na datong ipinasok mo at muling simulan ang proseso ng pagluluklok?", "config-restart": "Oo, muling simulan ito", "config-welcome": "=== Pagsusuring pangkapaligiran ===\nIsasagawa ang payak na mga pagsusuri upang makita kung ang kapaligirang ito ay angkop para sa pagluluklok ng MediaWiki.\nTandaan na dapat isama mo itong impormasyon kung kailangan mo ng tulong kung paano tapusin ang instalasyon.", - "config-copyright": "=== Karapatang-ari at Tadhana ===\n\n$1\n\nAng programang ito ay malayang software; maaari mo itong ipamahagi at/o baguhin sa ilalim ng mga tadhana ng Pangkalahatang Pampublikong Lisensiyang GNU ayon sa pagkakalathala ng Free Software Foundation; na maaaring bersyong 2 ng Lisensiya, o (kung nais mo) anumang susunod na bersyon.\n\nIpinamamahagi ang programang ito na umaasang magiging gamitin, subaliut '''walang anumang katiyakan'''; na walang pahiwatig ng '''pagiging mabenta''' o '''kaangkupan para sa isang tiyak na layunin'''.\nTingnan ang Pangkalahatang Pampublikong Lisensiyang GNU para sa mas maraming detalye.\n\nDapat nakatanggap ka ng isang sipi ng Pangkalahatang Pampublikong Lisensiyang GNU kasama ng programang ito; kung hindi, sumulat sa Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, o [https://www.gnu.org/licenses//gpl.html basahin ito sa Internet].", + "config-welcome-section-copyright": "=== Karapatang-ari at Tadhana ===\n\n$1\n\nAng programang ito ay malayang software; maaari mo itong ipamahagi at/o baguhin sa ilalim ng mga tadhana ng Pangkalahatang Pampublikong Lisensiyang GNU ayon sa pagkakalathala ng Free Software Foundation; na maaaring bersyong 2 ng Lisensiya, o (kung nais mo) anumang susunod na bersyon.\n\nIpinamamahagi ang programang ito na umaasang magiging gamitin, subaliut '''walang anumang katiyakan'''; na walang pahiwatig ng '''pagiging mabenta''' o '''kaangkupan para sa isang tiyak na layunin'''.\nTingnan ang Pangkalahatang Pampublikong Lisensiyang GNU para sa mas maraming detalye.\n\nDapat nakatanggap ka ng [$2 isang sipi ng Pangkalahatang Pampublikong Lisensiyang GNU] kasama ng programang ito; kung hindi, sumulat sa Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, o [https://www.gnu.org/licenses//gpl.html basahin ito sa Internet].", "config-sidebar": "* [https://www.mediawiki.org Tahanan ng MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Gabay ng Tagagamit]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Gabay ng Tagapangasiwa]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Mga Malimit Itanong]\n----\n* Basahin ako\n* Mga tala ng paglalabas\n* Pagkopya\n* Pagsasapanahon", "config-env-good": "Nasuri na ang kapaligiran.\nMailuluklok mo ang MediaWiki.", "config-env-bad": "Nasuri na ang kapaligiran.\nHindi mo mailuklok ang MediaWiki.", @@ -155,8 +155,6 @@ "config-db-web-no-create-privs": "Ang tinukoy mong account na iluluklok ay walang sapat na mga pribilehiyo upang makalikha ng isang account.\nAng account na tutukuyin mo rito ay umiiral na dapat.", "config-mysql-engine": "Makinang imbakan:", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "'''Babala''': Pinili mo ang MyISAM bilang makinang imbakan para sa MySQL, na hindi iminumungkahi para gamitin sa MediaWiki, sapagkat:\n* bahagya lamang itong sumusuporta ng pagkakasundu-sundo dahil sa pagkakandado ng talahanayan\n* mas malaki ang pagkakataon na kapitan ng sira kaysa sa ibang mga makina\n* ang himpilang kodigo ng MediaWiki ay hindi palaging humahawak ng MyISAM ayon sa nararapat\n\nKung ang iyong nakaluklok na MySQL ay sumusuporta ng InnoDB, higit na iminumungkahi na piliin mo iyon sa halip.\nKung ang iyong nakaluklok na MySQL ay hindi sumusuporta ng InnoDB, marahil ay panahon na para sa isang pagtataas ng uri.", "config-mysql-engine-help": "Ang '''InnoDB''' ay ang halos palaging pinaka mainam na mapipili, dahil mayroon itong mabuting suporta ng pagkakasundu-sundo.\n\nMaaaring mas mabilis ang '''MyISAM''' sa mga pagluluklok na pang-isahang tagagamit o mababasa lamang.\nMay gawi ang mga kalipunan ng dato ng MyISAM na masira nang mas madalas kaysa sa mga kalipunan ng dato ng InnoDB.", "config-site-name": "Pangalan ng wiki:", "config-site-name-help": "Lilitaw ito sa bareta ng pamagat ng pantingin-tingin at sa samu't saring ibang mga lugar.", diff --git a/includes/installer/i18n/tr.json b/includes/installer/i18n/tr.json index feb682acd9..e2a46836f8 100644 --- a/includes/installer/i18n/tr.json +++ b/includes/installer/i18n/tr.json @@ -60,7 +60,7 @@ "config-help-restart": "Girişini yaptığınız tüm kayıtlı verileri silerek, yükleme işlemini yeniden başlatmak ister misiniz?", "config-restart": "Evet, yeniden başlat", "config-welcome": "===Ortam Kontrolleri===\nOrtamın Mediawiki kurulumuna uygun olup olmadığını anlamak için basit kontroller yapılacak.\nKurulumu nasıl tamamlayacağınız konusunda destek isterken bu bilgileri eklemeyi unutmayın.", - "config-copyright": "=== Telif Hakları ve Koşulları ===\n\n$1\n\nBu program ücretsiz bir yazılımdır; yeniden dağıtabilir veya Özgür Yazılım Kuruluşu tarafından yayınlanan (GNU) Genel Kamu Lisansı koşulları altında değiştirebilirsiniz; isterseniz ikinci lisans sürümünü veya (sizin seçeneğiniz) herhangi bir sonraki lisans sürümünü kullanabilirsiniz.\n\nBu program, faydalı olacağı umuduyla dağıtılmaktadır, ancak ''' herhangi bir garantisi yoktur '''; ''' uygunluk ''' veya ''' belirli bir amaca uygunluk ''' gibi dolaylı garantileri bile yoktur.\nDaha fazla ayrıntı için (GNU) Genel Kamu Lisansına bakınız.\n\nBu program ile birlikte bir (GNU) Genel Kamu Lisansının bir kopyasını almış olmanız gerekir; bu program (GNU) Genel Kamu Lisansı ile dağıtılmadıysa, Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, ABD adresine yazın veya [https://www.gnu.org/copyleft/gpl.html online olarak okuyun].", + "config-welcome-section-copyright": "=== Telif Hakları ve Koşulları ===\n\n$1\n\nBu program ücretsiz bir yazılımdır; yeniden dağıtabilir veya Özgür Yazılım Kuruluşu tarafından yayınlanan (GNU) Genel Kamu Lisansı koşulları altında değiştirebilirsiniz; isterseniz ikinci lisans sürümünü veya (sizin seçeneğiniz) herhangi bir sonraki lisans sürümünü kullanabilirsiniz.\n\nBu program, faydalı olacağı umuduyla dağıtılmaktadır, ancak ''' herhangi bir garantisi yoktur '''; ''' uygunluk ''' veya ''' belirli bir amaca uygunluk ''' gibi dolaylı garantileri bile yoktur.\nDaha fazla ayrıntı için (GNU) Genel Kamu Lisansına bakınız.\n\nBu program ile birlikte [$2 bir (GNU) Genel Kamu Lisansının bir kopyasını ] almış olmanız gerekir; bu program (GNU) Genel Kamu Lisansı ile dağıtılmadıysa, Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, ABD adresine yazın veya [https://www.gnu.org/copyleft/gpl.html online olarak okuyun].", "config-sidebar": "* [https://www.mediawiki.org MediaWiki anasayfa]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Kullanıcı Kılavuzu]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Hizmetli Rehberi]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ SSS]\n----\n* Beni oku\n* Sürüm notları\n* Kopyalama\n* Yükseltme", "config-env-good": "Ortam kontrol edildi.\nMediaWiki'yi kurabilirsiniz.", "config-env-bad": "Ortam kontrol edildi.\nMediaWiki'yi kuramazsınız.", @@ -155,7 +155,6 @@ "config-db-web-no-create-privs": "Kurulum için belirlediğiniz hesap, hesap yaratımı için gerekli izinlere sahip değil.\nBurada belirttiğiniz hesap halihazırda var olmalı.", "config-mysql-engine": "Depolama motoru:", "config-mysql-innodb": "InnoDB (önerilen)", - "config-mysql-myisam": "MyISAM", "config-mssql-auth": "Kimlik doğrulama türü:", "config-mssql-install-auth": "Kurulum işlemi sırasında veritabanına bağlanmak için kullanılacak doğrulama türünü seçin.\n\"{{int:config-mssql-windowsauth}}\"'ı seçerseniz,ağ sunucusu olarak çalışan kullanıcının kimlik bilgileri kullanılacaktır.", "config-mssql-sqlauth": "SQL Server kimlik doğrulaması", diff --git a/includes/installer/i18n/tt-cyrl.json b/includes/installer/i18n/tt-cyrl.json index bc222a56da..85aa98f004 100644 --- a/includes/installer/i18n/tt-cyrl.json +++ b/includes/installer/i18n/tt-cyrl.json @@ -3,7 +3,8 @@ "authors": [ "KhayR", "Seb35", - "Ильнар" + "Ильнар", + "Ерней" ] }, "config-desc": "MediaWiki йөкләүче", @@ -39,12 +40,12 @@ "config-db-host": "Мәгълүмат базасы хосты:", "config-db-host-oracle": "TNS мәгълүмат базасы:", "config-db-wiki-settings": "Бу вики идентификациясе", - "config-db-name": "Мәгълүмат базасы исеме:", + "config-db-name": "Мәгълүматлар базасы исеме (сызыкчасыз):", "config-db-name-oracle": "Мәгълүмат базасы төзелеше:", "config-db-username": "Мәгълүмат базасын кулланучы исеме:", "config-db-password": "Мәгълүмат базасының серсүзе:", "config-db-port": "Мәгълүмат базасы порты:", - "config-db-schema": "MediaWiki өчен төзелеш:", + "config-db-schema": "MediaWiki өчен (сызыкчасыз) төзелеш:", "config-type-mysql": "MariaDB, MySQL яисә ярашлы", "config-type-mssql": "Microsoft SQL Server", "config-header-mysql": "MariaDB/MySQL көйләнмәләре", @@ -58,7 +59,6 @@ "config-show-table-status": "«SHOW TABLE STATUS» таләбе эшләнмәде!", "config-mysql-engine": "Саклау системасы:", "config-mysql-innodb": "InnoDB (тәкъдим ителә)", - "config-mysql-myisam": "MyISAM", "config-mssql-auth": "Аутентификация төре:", "config-mssql-sqlauth": "SQL Server чынлыгын раслау", "config-mssql-windowsauth": "Windows чынлыгын раслау", diff --git a/includes/installer/i18n/uk.json b/includes/installer/i18n/uk.json index 321caefd37..3ae74e747b 100644 --- a/includes/installer/i18n/uk.json +++ b/includes/installer/i18n/uk.json @@ -55,14 +55,18 @@ "config-help-restart": "Ви бажаєте видалити всі введені та збережені вами дані і запустити процес установки спочатку?", "config-restart": "Так, перезапустити установку", "config-welcome": "=== Перевірка оточення ===\nБудуть проведені базові перевірки, щоб виявити, чи можлива установка MediaWiki у даній системі.\nНе забудьте включити цю інформацію, якщо ви звернетеся по підтримку, як завершити установку.", - "config-copyright": "=== Авторське право і умови ===\n\n$1\n\nЦя програма є вільним програмним забезпеченням; Ви можете розповсюджувати та/або змінювати її під ліцензією GNU General Public License, опублікованою Фондом вільного програмного забезпечення; версією 2 цієї ліцензії або будь-якою пізнішою на Ваш вибір.\n\nЦя програма поширюється з надією на те, що вона буде корисною, однак '''без жодних гарантій'''; навіть без неявної гарантії '''комерційної цінності''' або '''придатності для певних цілей'''.\nДив. GNU General Public License для детальної інформації.\n\nВи повинні були отримати копію GNU General Public License разом із цією програмою; якщо ж ні, зверніться до Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. або [https://www.gnu.org/copyleft/gpl.html ознайомтесь з нею онлайн].", - "config-sidebar": "* [https://www.mediawiki.org Сайт MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Посібник користувача]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Посібник адміністратора]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]\n----\n* Read me\n* Інформація про випуск\n* Ліцензія\n* Оновлення", + "config-welcome-section-copyright": "=== Авторське право і умови ===\n\n$1\n\nЦя програма є вільним програмним забезпеченням; Ви можете розповсюджувати та/або змінювати її під ліцензією GNU General Public License, опублікованою Фондом вільного програмного забезпечення; версією 2 цієї ліцензії або будь-якою пізнішою на Ваш вибір.\n\nЦя програма поширюється з надією на те, що вона буде корисною, однак '''без жодних гарантій'''; навіть без неявної гарантії '''комерційної цінності''' або '''придатності для певних цілей'''.\nДив. GNU General Public License для детальної інформації.\n\nВи повинні були отримати [$2 копію GNU General Public License] разом із цією програмою; якщо ж ні, зверніться до Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. або [https://www.gnu.org/copyleft/gpl.html ознайомтесь з нею онлайн].", + "config-sidebar": "* [https://www.mediawiki.org Сайт MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Посібник користувача]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Посібник адміністратора]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]", + "config-sidebar-readme": "Прочитай мене", + "config-sidebar-relnotes": "Інформація про версію", + "config-sidebar-license": "Копіювання", + "config-sidebar-upgrade": "Оновлення", "config-env-good": "Перевірку середовища успішно завершено.\nВи можете встановити MediaWiki.", "config-env-bad": "Було проведено перевірку середовища. Ви не можете встановити MediaWiki.", "config-env-php": "Встановлено версію PHP: $1.", "config-env-hhvm": "HHVM $1 встановлено.", - "config-unicode-using-intl": "Використовувати [https://pecl.php.net/intl міжнародне розширення PECL] для нормалізації Юнікоду.", - "config-unicode-pure-php-warning": "'''Увага''': [https://pecl.php.net/intl міжнародне розширення PECL] не може провести нормалізацію Юнікоду.\nЯкщо ваш сайт має високий трафік, вам варто почитати про [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations нормалізацію Юнікоду].", + "config-unicode-using-intl": "За допомогою [https://php.net/manual/en/book.intl.php PHP-розширення intl] для нормалізації Юнікоду.", + "config-unicode-pure-php-warning": "'''Увага''': [https://php.net/manual/en/book.intl.php PHP-розширення intl] не може провести нормалізацію Юнікоду.\nЯкщо ваш сайт має високий трафік, вам варто почитати про [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations нормалізацію Юнікоду].", "config-unicode-update-warning": "'''Увага''': Встановлена версія обгортки нормалізації Юнікоду використовує стару версію бібліотеки [http://site.icu-project.org/ проекту ICU].\nВи маєте [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations оновити версію], якщо плануєте повноцінно використовувати Юнікод.", "config-no-db": "Не вдалося знайти потрібний драйвер бази даних! Вам необхідно встановити драйвер бази даних для PHP. Підтримуються {{PLURAL:$2|такий тип|такі типи}} баз даних: $1.\n\nЯкщо ви скомпілювали PHP самостійно, переналаштуйте його з увімкненим клієнтом бази даних, наприклад за допомогою ./configure --with-mysqli.\n\nЯкщо установлено PHP з пакетів Debian або Ubuntu, тоді ви також повинні встановити, наприклад, пакунок php-mysql.", "config-outdated-sqlite": "Увага: у Вас встановлена версія SQLite $2, а це нижче, ніж мінімально необхідна версія $1. SQLite буде недоступним.", @@ -126,8 +130,8 @@ "config-support-info": "MediaWiki підтримує такі системи баз даних:\n\n$1\n\nЯкщо Ви не бачите серед перерахованих систему баз даних, яку використовуєте, виконайте вказівки, вказані вище, щоб увімкнути підтримку.", "config-dbsupport-mysql": "* [{{int:version-db-mariadb-url}} MariaDB] є основною ціллю для MediaWiki і найкраще підтримується. MediaWiki також працює з [{{int:version-db-mysql-url}} MySQL] та [{{int:version-db-percona-url}} Percona Server], які сумісні з MariaDB. ([https://www.php.net/manual/en/mysqli.installation.php Як зібрати PHP з підтримкою MySQL])", "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] — популярна відкрита СУБД, альтернатива MySQL. ([https://www.php.net/manual/en/pgsql.installation.php як зібрати PHP з допомогою PostgreSQL]).", - "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] — легка система баз даних, яка дуже добре підтримується. ([http://www.php.net/manual/en/pdo.installation.php Як зібрати PHP з допомогою SQLite], що використовує PDO)", - "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] — комерційна база даних масштабу підприємства. ([http://www.php.net/manual/en/oci8.installation.php Як зібрати PHP з підтримкою OCI8])", + "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] — легка система баз даних, яка дуже добре підтримується. ([http://www.php.net/manual/en/pdo.installation.php Як зібрати PHP з допомогою SQLite], використовує PDO)", + "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] — комерційна база даних масштабу підприємства. ([http://www.php.net/manual/en/oci8.installation.php Як зібрати PHP з підтримкою OCI8])", "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQL Server] — це комерційна база даних для Windows масштабу підприємства. ([https://www.php.net/manual/en/sqlsrv.installation.php Як зібрати PHP з підтримкою SQLSRV])", "config-header-mysql": "Налаштування MariaDB/MySQL", "config-header-postgres": "Налаштування PostgreSQL", @@ -170,9 +174,6 @@ "config-db-web-no-create-privs": "Обліковий запис, вказаний Вами для встановлення, не має достатніх повноважень для створення облікового запису.\nОбліковий запис, який Ви вказуєте тут, уже повинен існувати.", "config-mysql-engine": "Двигун бази даних:", "config-mysql-innodb": "InnoDB (рекомендовано)", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "'''Увага''': Ви обрали MyISAM для зберігання даних MySQL, що не рекомендовано для роботи з MediaWiki, оскільки:\n* він слабко підтримує паралелізм через блокування таблиць\n* він більш схильний до ушкоджень, ніж інші двигуни\n* база коду MediaWiki не завжди працює з MyISAM так, як мала б.\n\nЯкщо Ваша інсталяція MySQL підтримує InnoDB, дуже рекомендується вибрати цей двигун.\nЯкщо Ваша інсталяція MySQL не підтримує InnoDB, можливо настав час її оновити.", - "config-mysql-only-myisam-dep": "\"'Зауваження:\"' MyISAM є єдиним механізмом для зберігання MySQL на цій машині, який не рекомендується для використання з MediaWiki, оскільки:\n* слабо підтримує паралелізм через блокування таблиць\n* більш схильний до пошкоджень, ніж інші двигуни\n* код MediaWiki не завжди розглядає MyISAM, як повинен\n\nТвоє встановлення MySQL не підтримує InnoDB, можливо, потрібно оновити.", "config-mysql-engine-help": "'''InnoDB''' є завжди кращим вибором, оскільки краще підтримує паралельний доступ.\n\n'''MyISAM''' може бути швидшим для одного користувача або в інсталяціях read-only.\nБази даних MyISAM схильні псуватись частіше, ніж бази InnoDB.", "config-mssql-auth": "Тип автентифікації:", "config-mssql-install-auth": "Виберіть тип перевірки автентичності, який буде використовуватися для підключення до бази даних під час процесу установки. \nЯкщо ви оберете \"{{int:config-mssql-windowsauth}}\", будуть використовуватися облікові дані користувача, під яким працює веб-сервер.", @@ -230,7 +231,7 @@ "config-license-help": "Чимало загальнодоступних вікі публікують увесь свій вміст під [https://freedomdefined.org/Definition вільною ліцензією]. Це розвиває відчуття спільної власності і заохочує довготривалу участь. У загальному випадку для приватної чи корпоративної вікі у цьому немає необхідності.\n\nЯкщо Ви хочете мати змогу використовувати текст з Вікіпедії і дати Вікіпедії змогу використовувати текст, скопійований з Вашої вікі, вам необхідно обрати {{int:config-license-cc-by-sa}}.\n\nРаніше Вікіпедія використовувала GNU Free Documentation License.\nGFDL — допустима ліцензія, але у ній важко розібратися, а контент під GFDL важко використовувати повторно.", "config-email-settings": "Налаштування електронної пошти", "config-enable-email": "Увімкнути вихідну електронну пошту", - "config-enable-email-help": "Якщо Ви хочете, що електронна пошта працювала, необхідно виставити коректні [Config-dbsupport-oracle/manual/en/mail.configuration.php налаштування пошти у PHP].\nЯкщо Вам не потрібні жодні можливості електронної пошти у вікі, можете тут їх відімкнути.", + "config-enable-email-help": "Якщо Ви хочете, що електронна пошта працювала, необхідно виставити коректні [https://www.php.net/manual/en/mail.configuration.php налаштування пошти у PHP].\nЯкщо Вам не потрібні жодні можливості електронної пошти у вікі, можете тут їх відімкнути.", "config-email-user": "Увімкнути електронну пошту користувач-користувачеві", "config-email-user-help": "Дозволити усім користувачам надсилати один одному електронну пошту, якщо вони увімкнули цю можливість у своїх налаштуваннях.", "config-email-usertalk": "Увімкнути сповіщення про повідомлення на сторінці обговорення користувача", diff --git a/includes/installer/i18n/vi.json b/includes/installer/i18n/vi.json index cedcee1489..0929c1b5b1 100644 --- a/includes/installer/i18n/vi.json +++ b/includes/installer/i18n/vi.json @@ -8,7 +8,8 @@ "Nguyên Lê", "Macofe", "Leducthn", - "Vinhtantran" + "Vinhtantran", + "Tuanminh01" ] }, "config-desc": "Trình cài đặt MediaWiki", @@ -48,14 +49,18 @@ "config-help-restart": "Bạn có muốn xóa tất cả dữ liệu được lưu mà bạn vừa nhập và khởi động lại quá trình cài đặt?", "config-restart": "Có, khởi động lại nó", "config-welcome": "=== Kiểm tra môi trường ===\nBây giờ sẽ kiểm tra sơ qua môi trường này có phù hợp cho việc cài đặt MediaWiki.\nHãy nhớ bao gồm thông tin này khi nào xin hỗ trợ hoàn thành việc cài đặt.", - "config-copyright": "=== Bản quyền và Điều khoản ===\n\n$1\n\nChương trình này là phần mềm tự do; bạn được phép tái phân phối và/hoặc sửa đổi nó theo những điều khoản của Giấy phép Công cộng GNU do Quỹ Phần mềm Tự do xuất bản; phiên bản 2 hay bất kỳ phiên bản nào mới hơn nào của Giấy phép (tùy bạn lựa chọn).\n\nChương trình này được phân phối với hy vọng rằng nó sẽ hữu ích, nhưng không có bất kỳ một đảm bảo nào, ngay cả những bảo đảm ngụ ý cho tính thương mại hoặc phù hợp với mục đích đặc biệt nào đó. \nXem Giấy phép Công cộng GNU để biết thêm chi tiết.\n\nCó lẽ bạn đã nhận được bản sao Giấy phép Công cộng GNU đi kèm với chương trình này; nếu không, hãy viết thư đến:\n Free Software Foundation, Inc.\n 51 Franklin St., Fifth Floor\n Boston, MA 02110-1301\n USA\nhoặc [https://www.gnu.org/copyleft/gpl.html đọc nó trực tuyến].", - "config-sidebar": "* [https://www.mediawiki.org/wiki/Special:MyLanguage/MediaWiki Trang chủ MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Hướng dẫn sử dụng]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Hướng dẫn quản lý]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Câu thường hỏi]\n----\n* Cần đọc trước\n* Ghi chú phát hành\n* Sao chép\n* Nâng cấp", + "config-welcome-section-copyright": "=== Bản quyền và Điều khoản ===\n\n$1\n\nChương trình này là phần mềm tự do; bạn được phép tái phân phối và/hoặc sửa đổi nó theo những điều khoản của Giấy phép Công cộng GNU do Quỹ Phần mềm Tự do xuất bản; phiên bản 2 hay bất kỳ phiên bản nào mới hơn nào của Giấy phép (tùy bạn lựa chọn).\n\nChương trình này được phân phối với hy vọng rằng nó sẽ hữu ích, nhưng không có bất kỳ một đảm bảo nào, ngay cả những bảo đảm ngụ ý cho tính thương mại hoặc phù hợp với mục đích đặc biệt nào đó. \nXem Giấy phép Công cộng GNU để biết thêm chi tiết.\n\nCó lẽ bạn đã nhận được [$2 bản sao Giấy phép Công cộng GNU] đi kèm với chương trình này; nếu không, hãy viết thư đến:\n Free Software Foundation, Inc.\n 51 Franklin St., Fifth Floor\n Boston, MA 02110-1301\n USA\nhoặc [https://www.gnu.org/copyleft/gpl.html đọc nó trực tuyến].", + "config-sidebar": "* [https://www.mediawiki.org/wiki/Special:MyLanguage/MediaWiki Trang chủ MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Hướng dẫn sử dụng]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Hướng dẫn quản lý]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Câu thường hỏi]", + "config-sidebar-readme": "Đọc thêm", + "config-sidebar-relnotes": "Thông báo phát hành", + "config-sidebar-license": "Sao chép", + "config-sidebar-upgrade": "Nâng cấp", "config-env-good": "Đã kiểm tra môi trường.\nBạn có thể cài đặt MediaWiki.", "config-env-bad": "Đã kiểm tra môi trường.\nBạn không thể cài đặt MediaWiki.", "config-env-php": "PHP $1 đã được cài đặt.", "config-env-hhvm": "HHVM $1 được cài đặt.", "config-unicode-using-intl": "Sẽ sử dụng [https://pecl.php.net/intl phần mở rộng PECL intl] để chuẩn hóa Unicode.", - "config-unicode-pure-php-warning": "Cảnh báo: [https://pecl.php.net/intl intl PECL extension] không được phép xử lý Unicode chuẩn hóa, trả lại thực thi PHP-gốc chậm.\nNếu bạn chạy một site lưu lượng lớn, bạn phải để ý qua một chút trên [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode normalization].", + "config-unicode-pure-php-warning": "Cảnh báo: [https://pecl.php.net/intl PECL intl extension] không được phép xử lý Unicode chuẩn hóa, trả lại thực thi PHP-gốc chậm.\nNếu bạn chạy một site lưu lượng lớn, bạn nên đọc qua [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations chuẩn hóa Unicode].", "config-unicode-update-warning": "Cảnh báo: Phiên bản cài đặt của gói Unicode chuẩn hóa sử dụng một phiên bản cũ của thư viện [http://site.icu-project.org/ the ICU project].\nBạn phải [https://www.mediawiki.org/wiki/Special:MyLanguage/nâng cấp Unicode_normalization_considerations] nếu bạn quan tâm đến việc sử dụng Unicode.", "config-no-db": "Không tìm thấy một trình điều khiển cơ sở dữ liệu phù hợp! Bạn cần phải cài một trình điều khiển cơ sở dữ liệu cho PHP.\n{{PLURAL:$2|Loại|Các loại}} cơ sở dữ liệu sau đây được hỗ trợ: $1.\n\nNếu bạn đã biên dịch PHP lấy, cấu hình lại nó mà kích hoạt một trình khách cơ sở dữ liệu, ví dụ bằng lệnh ./configure --with-mysqli.\nNếu bạn đã cài PHP từ một gói Debian hoặc Ubuntu, thì bạn cũng cần phải cài ví dụ gói php-mysql.", "config-outdated-sqlite": "Chú ý: Bạn có SQLite $1, phiên bản này thấp hơn phiên bản yêu câu tối thiểu $2. SQLite sẽ không có tác dụng.", @@ -163,9 +168,6 @@ "config-db-web-no-create-privs": "Tài khoản mà bạn xác định để cài đặt không có đủ quyền để tạo một tài khoản. Tài khoản mà bạn chỉ ra ở đây phải thực sự tồn tại trước đó.", "config-mysql-engine": "Máy lưu trữ:", "config-mysql-innodb": "InnoDB (khuyến khích)", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "Cảnh báo: Bạn đã chọn MyISAM làm động cơ lưu trữ cho MySQL, điều này không được khuyến khích sử dụng với MediaWiki, bởi vì:\n* Nó ít hỗ trợ đồng thời do việc khóa bảng\n* Nó dễ bị lỗi hơn so với các động cơ khác\n* Kho mã nguồn của MediaWiki không phải khi nào cũng xử lý MyISAM như mong muốn\n\nNếu cài đặt MySQL của bạn hỗ trợ InnoDB, đặc biệt khuyến cáo bạn nên chọn để thay thế.\nNếu cài đặt MySQL của bạn không hỗ trợ InnoDB, có lẽ đã đến lúc để nâng cấp.", - "config-mysql-only-myisam-dep": "Cảnh báo: MyISAM chỉ là công cụ lưu trữ có sẵn cho MySQL trên máy tính này, và điều này không được khuyến khích sử dụng với MediaWiki, bởi vì:\n* Nó ít hỗ trợ đồng thời do việc khóa khóa\n* Nó là dễ bị hư hỏng hơn các engine khác\n* Codebase MediaWiki không phải khi nào cũng xử lý MyISAM như mong muốn\n\nCài đặt MySQL của bạn không hỗ trợ InnoDB, có lẽ đã đến lúc để nâng cấp.", "config-mysql-engine-help": "InnoDB hầu như luôn là tùy chọn tốt nhất, vì nó có hỗ trợ đồng thời rất tốt.\n\nMyISAM có thể nhanh hơn trong chế độ một người dùng hoặc các cài đặt chỉ-đọc (read-only).\nCơ sở dữ liệu MyISAM có xu hướng thường xuyên bị hỏng hóc hơn so với cơ sở dữ liệu InnoDB.", "config-mssql-auth": "Kiểu xác thực:", "config-mssql-install-auth": "Chọn loại xác thực sẽ được sử dụng để kết nối với cơ sở dữ liệu trong quá trình cài đặt.\nNếu bạn chọn “{{int:config-mssql-windowsauth}}”, thông tin của bất cứ người sử dụng nào mà máy chủ web đang chạy sẽ được sử dụng.", diff --git a/includes/installer/i18n/war.json b/includes/installer/i18n/war.json index dca707f440..f53d856f22 100644 --- a/includes/installer/i18n/war.json +++ b/includes/installer/i18n/war.json @@ -60,7 +60,6 @@ "config-sqlite-cant-create-db": "Diri nakakahimo hin database file nga $1.", "config-db-web-account": "Database account para han web access", "config-mysql-innodb": "InnoDB", - "config-mysql-myisam": "MyISAM", "config-site-name": "Ngaran han wiki:", "config-ns-generic": "Proyekto", "config-ns-site-name": "Kapareho han wiki nga ngaran: $1", diff --git a/includes/installer/i18n/zh-hans.json b/includes/installer/i18n/zh-hans.json index 6035c61945..ecbfe026bd 100644 --- a/includes/installer/i18n/zh-hans.json +++ b/includes/installer/i18n/zh-hans.json @@ -65,7 +65,7 @@ "config-help-restart": "是否要清除所有已输入且保存的数据,并重新启动安装过程吗?", "config-restart": "是的,重启吧", "config-welcome": "=== 环境检查 ===\n将简单检查当前环境是否适合安装MediaWiki。如果您要寻求安装过程的支持,请记得附上此信息。", - "config-copyright": "=== 版权和条款 ===\n\n$1\n\n本程序为自由软件;您可依据自由软件基金会所发表的GNU通用公共授权条款规定,就本程序再为发布与/或修改;无论您依据的是本授权的第二版或(您自行选择的)任一日后发行的版本。\n\n本程序是基于使用目的而加以发布,然而'''不负任何担保责任''';亦无对'''适售性'''或'''特定目的适用性'''所为的默示性担保。详情请参照GNU通用公共授权。\n\n您应已收到附随于本程序的GNU通用公共授权的副本;如果没有,请写信至自由软件基金会:59 Temple Place - Suite 330, Boston, Ma 02111-1307, USA,或[https://www.gnu.org/copyleft/gpl.html 在线阅读]。", + "config-welcome-section-copyright": "=== 版权和条款 ===\n\n$1\n\n本程序为自由软件;您可依据自由软件基金会所发表的GNU通用公共授权条款规定,就本程序再为发布与/或修改;无论您依据的是本授权的第二版或(您自行选择的)任一日后发行的版本。\n\n本程序是基于使用目的而加以发布,然而'''不负任何担保责任''';亦无对'''适售性'''或'''特定目的适用性'''所为的默示性担保。详情请参照GNU通用公共授权。\n\n您应已收到附随于本程序的[$2 GNU通用公共授权的副本];如果没有,请写信至自由软件基金会:59 Temple Place - Suite 330, Boston, Ma 02111-1307, USA,或[https://www.gnu.org/copyleft/gpl.html 在线阅读]。", "config-sidebar": "* [https://www.mediawiki.org/wiki/MediaWiki/zh-hans MediaWiki首页]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents/zh-hans 用户指南]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents 管理员指南]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/zh-hans 常见问题解答]\n----\n* 自述文件\n* 发行说明\n* 协议副本\n* 升级", "config-env-good": "环境检查已经完成。您可以安装MediaWiki。", "config-env-bad": "环境检查已经完成。您不能安装MediaWiki。", @@ -179,9 +179,6 @@ "config-db-web-no-create-privs": "您指定给安装程序的帐号缺少创建帐号的权限,因此您指定的帐号必须已经存在。", "config-mysql-engine": "存储引擎:", "config-mysql-innodb": "InnoDB(推荐)", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "警告:您选择了MyISAM作为MySQL的存储引擎,MediaWiki并不推荐您这么做,因为:\n* 它仅能通过表锁定来勉强支持并发\n* 与其他引擎相比,它更容易被损坏\n* MediaWiki代码库并不总会去处理MyISAM\n\n如果您的MySQL程序支持InnoDB,我们高度推荐您使用该引擎替代MyISAM。\n如果您的MySQL程序不支持InnoDB,请考虑升级。", - "config-mysql-only-myisam-dep": "警告:MyISAM是MySQL在此机器上唯一可用的存储引擎,但它不适合用于MediaWiki,因为:\n*因为表级锁定,它几乎不支持并发。\n*它相比其他引擎更容易损坏。\n*MediaWiki代码不能总是按照预期操作MyISAM。\n\n你的MySQL不支持InnoDB,是时候升级了。", "config-mysql-engine-help": "InnoDB通常是最佳选项,因为它对并发操作有着良好的支持。\n\nMyISAM在单用户或只读环境下可能会有更快的性能表现。但MyISAM数据库出错的概率一般要大于InnoDB数据库。", "config-mssql-auth": "身份验证类型:", "config-mssql-install-auth": "选择安装过程中链接数据库时将采用的身份验证方式。如果您选择“{{int:config-mssql-windowsauth}}”,将使用运行服务器的用户的身份凭据。", diff --git a/includes/installer/i18n/zh-hant.json b/includes/installer/i18n/zh-hant.json index 7a304929cc..0443cc4473 100644 --- a/includes/installer/i18n/zh-hant.json +++ b/includes/installer/i18n/zh-hant.json @@ -55,23 +55,27 @@ "config-page-restart": "重新安裝", "config-page-readme": "讀我說明", "config-page-releasenotes": "發佈說明", - "config-page-copying": "複製", + "config-page-copying": "副本", "config-page-upgradedoc": "升級", "config-page-existingwiki": "現有的 wiki", "config-help-restart": "是否要清除所有已輸入且儲存的資料,並重新開始安裝程序嗎?", "config-restart": "是的,重新開始", "config-welcome": "=== 環境檢查 ===\n現在會做基本的檢查,檢查環境是否符合 MediaWiki 安裝所需。\n若您要尋求如何完成安裝的協助,請記得提供以下訊息。", - "config-copyright": "=== 版權聲明與授權條款 ===\n\n$1\n\n本程式為自由軟體;您可依據自由軟體基金會所發表的 GNU 通用公共授權條款規定,將本程式重新發佈與/或修改;無論您依據的是本授權條款的第二版或 (您可自行選擇) 之後的任何版本。\n\n本程式發佈的目的是希望可以提供幫助,但 不負任何擔保責任;亦無隱含對 適售性 或 特定用途的適用性 的情形擔保。詳情請參照 GNU 通用公共授權。\n\n您應已隨本程式收到 GNU 通用公共授權條款的副本;如果沒有,請信件通知自由軟體基金會,51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA,或 [https://www.gnu.org/copyleft/gpl.html 線上閱讀]。", - "config-sidebar": "* [https://www.mediawiki.org/wiki/MediaWiki/zh-hant MediaWiki 首頁]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents/zh 使用者指南]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents/zh 管理員指南]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/zh 常見問題集]\n----\n* 讀我說明\n* 發行說明\n* 版權聲明\n* 升級", + "config-welcome-section-copyright": "=== 版權聲明與授權條款 ===\n\n$1\n\n本程式為自由軟體;您可依據自由軟體基金會所發表的 GNU 通用公共授權條款規定,將本程式重新發佈與/或修改;無論您依據的是本授權條款的第二版或 (您可自行選擇) 之後的任何版本。\n\n本程式發佈的目的是希望可以提供幫助,但 不負任何擔保責任;亦無隱含對 適售性 或 特定用途的適用性 的情形擔保。詳情請參照 GNU 通用公共授權。\n\n您應已隨本程式收到 [$2 GNU 通用公共授權條款的副本];如果沒有,請信件通知自由軟體基金會,51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA,或 [https://www.gnu.org/copyleft/gpl.html 線上閱讀]。", + "config-sidebar": "* [https://www.mediawiki.org/wiki/MediaWiki/zh-hant MediaWiki 首頁]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents/zh 使用者指南]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents/zh 管理員指南]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/zh 常見問題集]", + "config-sidebar-readme": "讀我檔案", + "config-sidebar-relnotes": "發佈說明", + "config-sidebar-license": "副本", + "config-sidebar-upgrade": "升級", "config-env-good": "環境檢查已完成。\n您可以安裝 MediaWiki。", "config-env-bad": "環境檢查已完成。\n您無法安裝 MediaWiki。", "config-env-php": "PHP $1 已安裝。", "config-env-hhvm": "HHVM $1 已安裝。", - "config-unicode-using-intl": "使用 [https://pecl.php.net/intl intl PECL 擴充套件] 做 Unicode 正規化。", - "config-unicode-pure-php-warning": "警告: 無法使用 [https://pecl.php.net/intl intl PECL 擴充套件] 處理 Unicode 正規化,故回退使用純 PHP 實作的正規化程式,此方式處理速度較緩慢。\n\n如果您的網站瀏覽人次很高,您應先閱讀 [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations/zh Unicode 正規化]。", + "config-unicode-using-intl": "使用 [https://php.net/manual/en/book.intl.php PHP intl 擴充套件]做 Unicode 正規化。", + "config-unicode-pure-php-warning": "警告: 無法使用 [https://php.net/manual/en/book.intl.php PHP intl 擴充套件]處理 Unicode 正規化,故回退使用純 PHP 實作的正規化程式,此方式處理速度較緩慢。\n\n如果您的網站瀏覽人次很高,您應先閱讀 [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations/zh Unicode 正規化]。", "config-unicode-update-warning": "警告:目前安裝的 Unicode 正規化包裝程式使用了舊版 [http://site.icu-project.org/ ICU 計劃] 的程式庫。\n若您需要使用 Unicode,您應先進行 [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations 升級]。", "config-no-db": "找不到合適的資料庫驅動程式!您需要安裝 PHP 資料庫驅動程式。\n目前支援以下{{PLURAL:$2|類型|類型}}的資料庫: $1 。\n\n如果您是自行編譯 PHP,您必須重新設定並開啟資料庫客戶端,例:使用 ./configure --with-mysqli 指令參數。\n如果您是使用 Debian 或 Ubuntu 的套件安裝 PHP ,您則需要額外安裝,例:php-mysql 套件。", - "config-outdated-sqlite": "警告:您已安裝 SQLite $1,但是它的版本低於最低需求版本 $2。 因此您無法使用 SQLite。", + "config-outdated-sqlite": "警告:您已安裝 SQLite $2,但是它的版本低於最低需求版本 $1。 因此您無法使用 SQLite。", "config-no-fts3": "警告: SQLite 編譯時未包含 [//sqlite.org/fts3.html FTS3 模組],後台搜尋功能將無法使用。", "config-pcre-old": "嚴重: 需要使用 PCRE $1 或更新的版本。\n您的 PHP 執行檔使用的是 PCRE $2。\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE 詳細資訊]。", "config-pcre-no-utf8": "嚴重: PHP 的 PCRE 模組在編譯時未包含 PCRE_UTF8 支援。\nMediaWiki 需要支援 UTF-8 才可正常運作。", @@ -176,9 +180,6 @@ "config-db-web-no-create-privs": "您指定給安裝程序使用的帳號沒有足夠的權限建立新帳號。\n在此處必須指定已經存在的帳號。", "config-mysql-engine": "儲存引擎:", "config-mysql-innodb": "InnoDB(推薦)", - "config-mysql-myisam": "MyISAM", - "config-mysql-myisam-dep": "警告:您選擇用來做為 MySQL 的儲存引撆 MyISAM 並不建議使用在 MediaWiki,主要原因為:\n* MyISAM 使用的資料表鎖定較無法承受多人同時連線\n* 比起其他儲存引擎相,它較容易損壞\n* MediaWiki 程式碼並沒有針對 MyISAM 做特別的處理\n\n若您安裝的 MySQL 支援 InnoDB,我們強烈建議您改用 InnoDB。\n若您安裝的 MySQL 不支援 InnoDB,則應考慮升級 MySQL。", - "config-mysql-only-myisam-dep": "警告:您的伺服器上的 MySQL 唯一可用的儲存引擎是 MyISAM,但並不建議使用,主要原因為:\n* MyISAM 使用的資料表鎖定較無法承受多人同時連線\n* 比起其他儲存引擎相,它較容易損壞\n* MediaWiki 程式碼並沒有針對 MyISAM 做特別的處理\n\n若您安裝的 MySQL 不支援 InnoDB,則應考慮升級 MySQL。", "config-mysql-engine-help": "由於對同時連線有較好的處理能力,InnoDB 通常是最佳的選項。\n\nMyISAM 只在單人使用或者唯讀作業的情況之下才可能有較快的處理能力。\n相較於 InnoDB,MyISAM 也較容易出現資料損毀的情況。", "config-mssql-auth": "身份驗證類型:", "config-mssql-install-auth": "請選擇安裝程序中要用來連線資料庫使用的身份驗證類型。\n若您選擇 \"{{int:config-mssql-windowsauth}}\",不論網頁伺服器是使用何種身份執行都會使用這組驗證資料。", diff --git a/includes/jobqueue/Job.php b/includes/jobqueue/Job.php index 2f98d53d82..c87dedc722 100644 --- a/includes/jobqueue/Job.php +++ b/includes/jobqueue/Job.php @@ -52,9 +52,6 @@ abstract class Job implements RunnableJob { /** @var int Bitfield of JOB_* class constants */ protected $executionFlags = 0; - /** @var int Job must not be wrapped in the usual explicit LBFactory transaction round */ - const JOB_NO_EXPLICIT_TRX_ROUND = 1; - /** * Create the appropriate object to handle a specific job * @@ -154,11 +151,6 @@ abstract class Job implements RunnableJob { } } - /** - * @param int $flag JOB_* class constant - * @return bool - * @since 1.31 - */ public function hasExecutionFlag( $flag ) { return ( $this->executionFlags & $flag ) === $flag; } @@ -234,20 +226,10 @@ abstract class Job implements RunnableJob { : null; } - /** - * @return string|null Id of the request that created this job. Follows - * jobs recursively, allowing to track the id of the request that started a - * job when jobs insert jobs which insert other jobs. - * @since 1.27 - */ public function getRequestId() { return $this->params['requestId'] ?? null; } - /** - * @return int|null UNIX timestamp of when the job was runnable, or null - * @since 1.26 - */ public function getReadyTimestamp() { return $this->getReleaseTimestamp() ?: $this->getQueuedTimestamp(); } @@ -267,19 +249,10 @@ abstract class Job implements RunnableJob { return $this->removeDuplicates; } - /** - * @return bool Whether this job can be retried on failure by job runners - * @since 1.21 - */ public function allowRetries() { return true; } - /** - * @return int Number of actually "work items" handled in this job - * @see $wgJobBackoffThrottling - * @since 1.23 - */ public function workItemCount() { return 1; } @@ -380,20 +353,12 @@ abstract class Job implements RunnableJob { $this->teardownCallbacks[] = $callback; } - /** - * Do any final cleanup after run(), deferred updates, and all DB commits happen - * @param bool $status Whether the job, its deferred updates, and DB commit all succeeded - * @since 1.27 - */ public function teardown( $status ) { foreach ( $this->teardownCallbacks as $callback ) { call_user_func( $callback, $status ); } } - /** - * @return string - */ public function toString() { $paramString = ''; if ( $this->params ) { diff --git a/includes/jobqueue/JobQueue.php b/includes/jobqueue/JobQueue.php index f5ed7b91cb..e52f29529c 100644 --- a/includes/jobqueue/JobQueue.php +++ b/includes/jobqueue/JobQueue.php @@ -44,8 +44,8 @@ abstract class JobQueue { /** @var StatsdDataFactoryInterface */ protected $stats; - /** @var BagOStuff */ - protected $dupCache; + /** @var WANObjectCache */ + protected $wanCache; const QOS_ATOMIC = 1; // integer; "all-or-nothing" job insertions @@ -53,6 +53,14 @@ abstract class JobQueue { /** * @param array $params + * - type : A job type + * - domain : A DB domain ID + * - wanCache : An instance of WANObjectCache to use for caching [default: none] + * - stats : An instance of StatsdDataFactoryInterface [default: none] + * - claimTTL : Seconds a job can be claimed for exclusive execution [default: forever] + * - maxTries : Total times a job can be tried, assuming claims expire [default: 3] + * - order : Queue order, one of ("fifo", "timestamp", "random") [default: variable] + * - readOnlyReason : Mark the queue as read-only with this reason [default: false] * @throws JobQueueError */ protected function __construct( array $params ) { @@ -70,7 +78,7 @@ abstract class JobQueue { } $this->readOnlyReason = $params['readOnlyReason'] ?? false; $this->stats = $params['stats'] ?? new NullStatsdDataFactory(); - $this->dupCache = $params['stash'] ?? new EmptyBagOStuff(); + $this->wanCache = $params['wanCache'] ?? WANObjectCache::newEmpty(); } /** @@ -459,24 +467,23 @@ abstract class JobQueue { * @return bool */ protected function doDeduplicateRootJob( IJobSpecification $job ) { - if ( !$job->hasRootJobParams() ) { + $params = $job->hasRootJobParams() ? $job->getRootJobParams() : null; + if ( !$params ) { throw new JobQueueError( "Cannot register root job; missing parameters." ); } - $params = $job->getRootJobParams(); $key = $this->getRootJobCacheKey( $params['rootJobSignature'] ); - // Callers should call JobQueueGroup::push() before this method so that if the insert - // fails, the de-duplication registration will be aborted. Since the insert is - // deferred till "transaction idle", do the same here, so that the ordering is - // maintained. Having only the de-duplication registration succeed would cause - // jobs to become no-ops without any actual jobs that made them redundant. - $timestamp = $this->dupCache->get( $key ); // current last timestamp of this job - if ( $timestamp && $timestamp >= $params['rootJobTimestamp'] ) { + // Callers should call JobQueueGroup::push() before this method so that if the + // insert fails, the de-duplication registration will be aborted. Having only the + // de-duplication registration succeed would cause jobs to become no-ops without + // any actual jobs that made them redundant. + $timestamp = $this->wanCache->get( $key ); // last known timestamp of such a root job + if ( $timestamp !== false && $timestamp >= $params['rootJobTimestamp'] ) { return true; // a newer version of this root job was enqueued } // Update the timestamp of the last root job started at the location... - return $this->dupCache->set( $key, $params['rootJobTimestamp'], self::ROOTJOB_TTL ); + return $this->wanCache->set( $key, $params['rootJobTimestamp'], self::ROOTJOB_TTL ); } /** @@ -490,9 +497,8 @@ abstract class JobQueue { if ( $job->getType() !== $this->type ) { throw new JobQueueError( "Got '{$job->getType()}' job; expected '{$this->type}'." ); } - $isDuplicate = $this->doIsRootJobOldDuplicate( $job ); - return $isDuplicate; + return $this->doIsRootJobOldDuplicate( $job ); } /** @@ -501,14 +507,18 @@ abstract class JobQueue { * @return bool */ protected function doIsRootJobOldDuplicate( IJobSpecification $job ) { - if ( !$job->hasRootJobParams() ) { + $params = $job->hasRootJobParams() ? $job->getRootJobParams() : null; + if ( !$params ) { return false; // job has no de-deplication info } - $params = $job->getRootJobParams(); $key = $this->getRootJobCacheKey( $params['rootJobSignature'] ); // Get the last time this root job was enqueued - $timestamp = $this->dupCache->get( $key ); + $timestamp = $this->wanCache->get( $key ); + if ( $timestamp === false || $params['rootJobTimestamp'] > $timestamp ) { + // Update the timestamp of the last known root job started at the location... + $this->wanCache->set( $key, $params['rootJobTimestamp'], self::ROOTJOB_TTL ); + } // Check if a new root job was started at the location after this one's... return ( $timestamp && $timestamp > $params['rootJobTimestamp'] ); @@ -519,7 +529,7 @@ abstract class JobQueue { * @return string */ protected function getRootJobCacheKey( $signature ) { - return $this->dupCache->makeGlobalKey( + return $this->wanCache->makeGlobalKey( 'jobqueue', $this->domain, $this->type, diff --git a/includes/jobqueue/JobQueueDB.php b/includes/jobqueue/JobQueueDB.php index 7c78f40031..f7b8ed2f78 100644 --- a/includes/jobqueue/JobQueueDB.php +++ b/includes/jobqueue/JobQueueDB.php @@ -24,6 +24,7 @@ use Wikimedia\Rdbms\Database; use Wikimedia\Rdbms\DBConnectionError; use Wikimedia\Rdbms\DBError; use MediaWiki\MediaWikiServices; +use Wikimedia\Rdbms\IMaintainableDatabase; use Wikimedia\ScopedCallback; /** @@ -38,9 +39,7 @@ class JobQueueDB extends JobQueue { const MAX_JOB_RANDOM = 2147483647; // integer; 2^31 - 1, used for job_random const MAX_OFFSET = 255; // integer; maximum number of rows to skip - /** @var WANObjectCache */ - protected $cache; - /** @var IDatabase|DBError|null */ + /** @var IMaintainableDatabase|DBError|null */ protected $conn; /** @var array|null Server configuration array */ @@ -55,7 +54,6 @@ class JobQueueDB extends JobQueue { * If not specified, the primary DB cluster for the wiki will be used. * This can be overridden with a custom cluster so that DB handles will * be retrieved via LBFactory::getExternalLB() and getConnection(). - * - wanCache : An instance of WANObjectCache to use for caching. * @param array $params */ protected function __construct( array $params ) { @@ -66,8 +64,6 @@ class JobQueueDB extends JobQueue { } elseif ( isset( $params['cluster'] ) && is_string( $params['cluster'] ) ) { $this->cluster = $params['cluster']; } - - $this->cache = $params['wanCache'] ?? WANObjectCache::newEmpty(); } protected function supportedOrders() { @@ -104,7 +100,7 @@ class JobQueueDB extends JobQueue { protected function doGetSize() { $key = $this->getCacheKey( 'size' ); - $size = $this->cache->get( $key ); + $size = $this->wanCache->get( $key ); if ( is_int( $size ) ) { return $size; } @@ -120,7 +116,7 @@ class JobQueueDB extends JobQueue { } catch ( DBError $e ) { throw $this->getDBException( $e ); } - $this->cache->set( $key, $size, self::CACHE_TTL_SHORT ); + $this->wanCache->set( $key, $size, self::CACHE_TTL_SHORT ); return $size; } @@ -136,7 +132,7 @@ class JobQueueDB extends JobQueue { $key = $this->getCacheKey( 'acquiredcount' ); - $count = $this->cache->get( $key ); + $count = $this->wanCache->get( $key ); if ( is_int( $count ) ) { return $count; } @@ -152,7 +148,7 @@ class JobQueueDB extends JobQueue { } catch ( DBError $e ) { throw $this->getDBException( $e ); } - $this->cache->set( $key, $count, self::CACHE_TTL_SHORT ); + $this->wanCache->set( $key, $count, self::CACHE_TTL_SHORT ); return $count; } @@ -169,7 +165,7 @@ class JobQueueDB extends JobQueue { $key = $this->getCacheKey( 'abandonedcount' ); - $count = $this->cache->get( $key ); + $count = $this->wanCache->get( $key ); if ( is_int( $count ) ) { return $count; } @@ -190,7 +186,7 @@ class JobQueueDB extends JobQueue { throw $this->getDBException( $e ); } - $this->cache->set( $key, $count, self::CACHE_TTL_SHORT ); + $this->wanCache->set( $key, $count, self::CACHE_TTL_SHORT ); return $count; } @@ -345,7 +341,7 @@ class JobQueueDB extends JobQueue { /** @noinspection PhpUnusedLocalVariableInspection */ $scope = $this->getScopedNoTrxFlag( $dbw ); // Check cache to see if the queue has <= OFFSET items - $tinyQueue = $this->cache->get( $this->getCacheKey( 'small' ) ); + $tinyQueue = $this->wanCache->get( $this->getCacheKey( 'small' ) ); $invertedDirection = false; // whether one job_random direction was already scanned // This uses a replication safe method for acquiring jobs. One could use UPDATE+LIMIT @@ -385,7 +381,7 @@ class JobQueueDB extends JobQueue { ); if ( !$row ) { $tinyQueue = true; // we know the queue must have <= MAX_OFFSET rows - $this->cache->set( $this->getCacheKey( 'small' ), 1, 30 ); + $this->wanCache->set( $this->getCacheKey( 'small' ), 1, 30 ); continue; // use job_random } } @@ -510,32 +506,17 @@ class JobQueueDB extends JobQueue { * @return bool */ protected function doDeduplicateRootJob( IJobSpecification $job ) { - $params = $job->getParams(); - if ( !isset( $params['rootJobSignature'] ) ) { - throw new MWException( "Cannot register root job; missing 'rootJobSignature'." ); - } elseif ( !isset( $params['rootJobTimestamp'] ) ) { - throw new MWException( "Cannot register root job; missing 'rootJobTimestamp'." ); - } - $key = $this->getRootJobCacheKey( $params['rootJobSignature'] ); - // Callers should call JobQueueGroup::push() before this method so that if the insert - // fails, the de-duplication registration will be aborted. Since the insert is - // deferred till "transaction idle", do the same here, so that the ordering is + // Callers should call JobQueueGroup::push() before this method so that if the + // insert fails, the de-duplication registration will be aborted. Since the insert + // is deferred till "transaction idle", do the same here, so that the ordering is // maintained. Having only the de-duplication registration succeed would cause // jobs to become no-ops without any actual jobs that made them redundant. $dbw = $this->getMasterDB(); /** @noinspection PhpUnusedLocalVariableInspection */ $scope = $this->getScopedNoTrxFlag( $dbw ); - - $cache = $this->dupCache; $dbw->onTransactionCommitOrIdle( - function () use ( $cache, $params, $key ) { - $timestamp = $cache->get( $key ); // current last timestamp of this job - if ( $timestamp && $timestamp >= $params['rootJobTimestamp'] ) { - return true; // a newer version of this root job was enqueued - } - - // Update the timestamp of the last root job started at the location... - return $cache->set( $key, $params['rootJobTimestamp'], JobQueueDB::ROOTJOB_TTL ); + function () use ( $job ) { + parent::doDeduplicateRootJob( $job ); }, __METHOD__ ); @@ -581,7 +562,7 @@ class JobQueueDB extends JobQueue { */ protected function doFlushCaches() { foreach ( [ 'size', 'acquiredcount' ] as $type ) { - $this->cache->delete( $this->getCacheKey( $type ) ); + $this->wanCache->delete( $this->getCacheKey( $type ) ); } } @@ -789,7 +770,7 @@ class JobQueueDB extends JobQueue { /** * @throws JobQueueConnectionError - * @return IDatabase + * @return IMaintainableDatabase */ protected function getMasterDB() { try { @@ -801,7 +782,7 @@ class JobQueueDB extends JobQueue { /** * @param int $index (DB_REPLICA/DB_MASTER) - * @return IDatabase + * @return IMaintainableDatabase */ protected function getDB( $index ) { if ( $this->server ) { @@ -825,12 +806,16 @@ class JobQueueDB extends JobQueue { ? $lbFactory->getExternalLB( $this->cluster ) : $lbFactory->getMainLB( $this->domain ); - return ( $lb->getServerType( $lb->getWriterIndex() ) !== 'sqlite' ) + if ( $lb->getServerType( $lb->getWriterIndex() ) !== 'sqlite' ) { // Keep a separate connection to avoid contention and deadlocks; // However, SQLite has the opposite behavior due to DB-level locking. - ? $lb->getConnectionRef( $index, [], $this->domain, $lb::CONN_TRX_AUTOCOMMIT ) + $flags = $lb::CONN_TRX_AUTOCOMMIT; + } else { // Jobs insertion will be defered until the PRESEND stage to reduce contention. - : $lb->getConnectionRef( $index, [], $this->domain ); + $flags = 0; + } + + return $lb->getMaintenanceConnectionRef( $index, [], $this->domain, $flags ); } } @@ -856,7 +841,7 @@ class JobQueueDB extends JobQueue { private function getCacheKey( $property ) { $cluster = is_string( $this->cluster ) ? $this->cluster : 'main'; - return $this->cache->makeGlobalKey( + return $this->wanCache->makeGlobalKey( 'jobqueue', $this->domain, $cluster, diff --git a/includes/jobqueue/JobQueueFederated.php b/includes/jobqueue/JobQueueFederated.php index 8b5a62ef54..beab4c6bae 100644 --- a/includes/jobqueue/JobQueueFederated.php +++ b/includes/jobqueue/JobQueueFederated.php @@ -88,8 +88,6 @@ class JobQueueFederated extends JobQueue { ) { unset( $baseConfig[$o] ); // partition queue doesn't care about this } - // The class handles all aggregator calls already - unset( $baseConfig['aggregator'] ); // Get the partition queue objects foreach ( $partitionMap as $partition => $w ) { if ( !isset( $params['configByPartition'][$partition] ) ) { diff --git a/includes/jobqueue/JobQueueGroup.php b/includes/jobqueue/JobQueueGroup.php index 2937d01238..06cd04ce6d 100644 --- a/includes/jobqueue/JobQueueGroup.php +++ b/includes/jobqueue/JobQueueGroup.php @@ -121,7 +121,6 @@ class JobQueueGroup { $services = MediaWikiServices::getInstance(); $conf['stats'] = $services->getStatsdDataFactory(); $conf['wanCache'] = $services->getMainWANObjectCache(); - $conf['stash'] = $services->getMainObjectStash(); return JobQueue::factory( $conf ); } @@ -271,10 +270,10 @@ class JobQueueGroup { /** * Acknowledge that a job was completed * - * @param Job $job + * @param RunnableJob $job * @return void */ - public function ack( Job $job ) { + public function ack( RunnableJob $job ) { $this->get( $job->getType() )->ack( $job ); } @@ -282,10 +281,10 @@ class JobQueueGroup { * Register the "root job" of a given job into the queue for de-duplication. * This should only be called right *after* all the new jobs have been inserted. * - * @param Job $job + * @param RunnableJob $job * @return bool */ - public function deduplicateRootJob( Job $job ) { + public function deduplicateRootJob( RunnableJob $job ) { return $this->get( $job->getType() )->deduplicateRootJob( $job ); } diff --git a/includes/jobqueue/JobQueueMemory.php b/includes/jobqueue/JobQueueMemory.php index cb20a76079..b26129ee91 100644 --- a/includes/jobqueue/JobQueueMemory.php +++ b/includes/jobqueue/JobQueueMemory.php @@ -33,9 +33,9 @@ class JobQueueMemory extends JobQueue { protected static $data = []; public function __construct( array $params ) { - parent::__construct( $params ); + $params['wanCache'] = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] ); - $this->dupCache = new HashBagOStuff(); + parent::__construct( $params ); } /** diff --git a/includes/jobqueue/JobQueueRedis.php b/includes/jobqueue/JobQueueRedis.php index 2140043b8f..569a5d4cb7 100644 --- a/includes/jobqueue/JobQueueRedis.php +++ b/includes/jobqueue/JobQueueRedis.php @@ -19,6 +19,8 @@ * * @file */ + +use MediaWiki\Logger\LoggerFactory; use Psr\Log\LoggerInterface; /** @@ -100,7 +102,7 @@ class JobQueueRedis extends JobQueue { "Non-daemonized mode is no longer supported. Please install the " . "mediawiki/services/jobrunner service and update \$wgJobTypeConf as needed." ); } - $this->logger = \MediaWiki\Logger\LoggerFactory::getInstance( 'redis' ); + $this->logger = LoggerFactory::getInstance( 'redis' ); } protected function supportedOrders() { @@ -134,7 +136,7 @@ class JobQueueRedis extends JobQueue { try { return $conn->lSize( $this->getQueueKey( 'l-unclaimed' ) ); } catch ( RedisException $e ) { - $this->throwRedisException( $conn, $e ); + throw $this->handleErrorAndMakeException( $conn, $e ); } } @@ -152,7 +154,7 @@ class JobQueueRedis extends JobQueue { return array_sum( $conn->exec() ); } catch ( RedisException $e ) { - $this->throwRedisException( $conn, $e ); + throw $this->handleErrorAndMakeException( $conn, $e ); } } @@ -166,7 +168,7 @@ class JobQueueRedis extends JobQueue { try { return $conn->zSize( $this->getQueueKey( 'z-delayed' ) ); } catch ( RedisException $e ) { - $this->throwRedisException( $conn, $e ); + throw $this->handleErrorAndMakeException( $conn, $e ); } } @@ -180,7 +182,7 @@ class JobQueueRedis extends JobQueue { try { return $conn->zSize( $this->getQueueKey( 'z-abandoned' ) ); } catch ( RedisException $e ) { - $this->throwRedisException( $conn, $e ); + throw $this->handleErrorAndMakeException( $conn, $e ); } } @@ -235,7 +237,7 @@ class JobQueueRedis extends JobQueue { throw new RedisException( $err ); } } catch ( RedisException $e ) { - $this->throwRedisException( $conn, $e ); + throw $this->handleErrorAndMakeException( $conn, $e ); } } @@ -332,7 +334,7 @@ LUA; $job = $this->getJobFromFields( $item ); // may be false } while ( !$job ); // job may be false if invalid } catch ( RedisException $e ) { - $this->throwRedisException( $conn, $e ); + throw $this->handleErrorAndMakeException( $conn, $e ); } return $job; @@ -426,7 +428,7 @@ LUA; $this->incrStats( 'acks', $this->type ); } catch ( RedisException $e ) { - $this->throwRedisException( $conn, $e ); + throw $this->handleErrorAndMakeException( $conn, $e ); } return true; @@ -449,7 +451,7 @@ LUA; $conn = $this->getConnection(); try { - $timestamp = $conn->get( $key ); // current last timestamp of this job + $timestamp = $conn->get( $key ); // last known timestamp of such a root job if ( $timestamp && $timestamp >= $params['rootJobTimestamp'] ) { return true; // a newer version of this root job was enqueued } @@ -457,7 +459,7 @@ LUA; // Update the timestamp of the last root job started at the location... return $conn->set( $key, $params['rootJobTimestamp'], self::ROOTJOB_TTL ); // 2 weeks } catch ( RedisException $e ) { - $this->throwRedisException( $conn, $e ); + throw $this->handleErrorAndMakeException( $conn, $e ); } } @@ -478,8 +480,7 @@ LUA; // Get the last time this root job was enqueued $timestamp = $conn->get( $this->getRootJobCacheKey( $params['rootJobSignature'] ) ); } catch ( RedisException $e ) { - $timestamp = false; - $this->throwRedisException( $conn, $e ); + throw $this->handleErrorAndMakeException( $conn, $e ); } // Check if a new root job was started at the location after this one's... @@ -507,7 +508,7 @@ LUA; return $ok; } catch ( RedisException $e ) { - $this->throwRedisException( $conn, $e ); + throw $this->handleErrorAndMakeException( $conn, $e ); } } @@ -521,7 +522,7 @@ LUA; try { $uids = $conn->lRange( $this->getQueueKey( 'l-unclaimed' ), 0, -1 ); } catch ( RedisException $e ) { - $this->throwRedisException( $conn, $e ); + throw $this->handleErrorAndMakeException( $conn, $e ); } return $this->getJobIterator( $conn, $uids ); @@ -537,7 +538,7 @@ LUA; try { $uids = $conn->zRange( $this->getQueueKey( 'z-delayed' ), 0, -1 ); } catch ( RedisException $e ) { - $this->throwRedisException( $conn, $e ); + throw $this->handleErrorAndMakeException( $conn, $e ); } return $this->getJobIterator( $conn, $uids ); @@ -553,7 +554,7 @@ LUA; try { $uids = $conn->zRange( $this->getQueueKey( 'z-claimed' ), 0, -1 ); } catch ( RedisException $e ) { - $this->throwRedisException( $conn, $e ); + throw $this->handleErrorAndMakeException( $conn, $e ); } return $this->getJobIterator( $conn, $uids ); @@ -569,7 +570,7 @@ LUA; try { $uids = $conn->zRange( $this->getQueueKey( 'z-abandoned' ), 0, -1 ); } catch ( RedisException $e ) { - $this->throwRedisException( $conn, $e ); + throw $this->handleErrorAndMakeException( $conn, $e ); } return $this->getJobIterator( $conn, $uids ); @@ -616,7 +617,7 @@ LUA; } } } catch ( RedisException $e ) { - $this->throwRedisException( $conn, $e ); + throw $this->handleErrorAndMakeException( $conn, $e ); } return $sizes; @@ -626,12 +627,12 @@ LUA; * This function should not be called outside JobQueueRedis * * @param string $uid - * @param RedisConnRef $conn + * @param RedisConnRef|Redis $conn * @return RunnableJob|bool Returns false if the job does not exist * @throws JobQueueError * @throws UnexpectedValueException */ - public function getJobFromUidInternal( $uid, RedisConnRef $conn ) { + public function getJobFromUidInternal( $uid, $conn ) { try { $data = $conn->hGet( $this->getQueueKey( 'h-data' ), $uid ); if ( $data === false ) { @@ -653,7 +654,7 @@ LUA; return $job; } catch ( RedisException $e ) { - $this->throwRedisException( $conn, $e ); + throw $this->handleErrorAndMakeException( $conn, $e ); } } @@ -672,7 +673,7 @@ LUA; $queues[] = $this->decodeQueueName( $queue ); } } catch ( RedisException $e ) { - $this->throwRedisException( $conn, $e ); + throw $this->handleErrorAndMakeException( $conn, $e ); } return $queues; @@ -754,7 +755,7 @@ LUA; /** * Get a connection to the server that handles all sub-queues for this queue * - * @return RedisConnRef + * @return RedisConnRef|Redis * @throws JobQueueConnectionError */ protected function getConnection() { @@ -770,11 +771,11 @@ LUA; /** * @param RedisConnRef $conn * @param RedisException $e - * @throws JobQueueError + * @return JobQueueError */ - protected function throwRedisException( RedisConnRef $conn, $e ) { + protected function handleErrorAndMakeException( RedisConnRef $conn, $e ) { $this->redisPool->handleError( $conn, $e ); - throw new JobQueueError( "Redis server error: {$e->getMessage()}\n" ); + return new JobQueueError( "Redis server error: {$e->getMessage()}\n" ); } /** diff --git a/includes/jobqueue/JobRunner.php b/includes/jobqueue/JobRunner.php index 676659f384..21d8c7e8af 100644 --- a/includes/jobqueue/JobRunner.php +++ b/includes/jobqueue/JobRunner.php @@ -23,7 +23,7 @@ use MediaWiki\MediaWikiServices; use MediaWiki\Logger\LoggerFactory; -use Liuggio\StatsdClient\Factory\StatsdDataFactory; +use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface; use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerInterface; use Wikimedia\ScopedCallback; @@ -265,13 +265,13 @@ class JobRunner implements LoggerAwareInterface { } /** - * @param Job $job + * @param RunnableJob $job * @param LBFactory $lbFactory - * @param StatsdDataFactory $stats + * @param StatsdDataFactoryInterface $stats * @param float $popTime * @return array Map of status/error/timeMs */ - private function executeJob( Job $job, LBFactory $lbFactory, $stats, $popTime ) { + private function executeJob( RunnableJob $job, LBFactory $lbFactory, $stats, $popTime ) { $jType = $job->getType(); $msg = $job->toString() . " STARTING"; $this->logger->debug( $msg, [ @@ -367,11 +367,11 @@ class JobRunner implements LoggerAwareInterface { } /** - * @param Job $job + * @param RunnableJob $job * @return int Seconds for this runner to avoid doing more jobs of this type * @see $wgJobBackoffThrottling */ - private function getBackoffTimeToWait( Job $job ) { + private function getBackoffTimeToWait( RunnableJob $job ) { $throttling = $this->config->get( 'JobBackoffThrottling' ); if ( !isset( $throttling[$job->getType()] ) || $job instanceof DuplicateJob ) { @@ -526,16 +526,16 @@ class JobRunner implements LoggerAwareInterface { * $wgJobSerialCommitThreshold for more. * * @param LBFactory $lbFactory - * @param Job $job + * @param RunnableJob $job * @param string $fnameTrxOwner * @throws DBError */ - private function commitMasterChanges( LBFactory $lbFactory, Job $job, $fnameTrxOwner ) { + private function commitMasterChanges( LBFactory $lbFactory, RunnableJob $job, $fnameTrxOwner ) { $syncThreshold = $this->config->get( 'JobSerialCommitThreshold' ); $time = false; $lb = $lbFactory->getMainLB(); - if ( $syncThreshold !== false && $lb->getServerCount() > 1 ) { + if ( $syncThreshold !== false && $lb->hasStreamingReplicaServers() ) { // Generally, there is one master connection to the local DB $dbwSerial = $lb->getAnyOpenConnection( $lb->getWriterIndex() ); // We need natively blocking fast locks diff --git a/includes/jobqueue/JobSpecification.php b/includes/jobqueue/JobSpecification.php index 80a46d04ba..19ff9677ea 100644 --- a/includes/jobqueue/JobSpecification.php +++ b/includes/jobqueue/JobSpecification.php @@ -27,8 +27,8 @@ * @code * $job = new JobSpecification( * 'null', - * array( 'lives' => 1, 'usleep' => 100, 'pi' => 3.141569 ), - * array( 'removeDuplicates' => 1 ) + * [ 'lives' => 1, 'usleep' => 100, 'pi' => 3.141569 ], + * [ 'removeDuplicates' => 1 ] * ); * JobQueueGroup::singleton()->push( $job ) * @endcode diff --git a/includes/jobqueue/RunnableJob.php b/includes/jobqueue/RunnableJob.php index e477b12c1e..dba2ae9891 100644 --- a/includes/jobqueue/RunnableJob.php +++ b/includes/jobqueue/RunnableJob.php @@ -33,6 +33,9 @@ * @since 1.33 */ interface RunnableJob extends IJobSpecification { + /** @var int Job must not be wrapped in the usual explicit LBFactory transaction round */ + const JOB_NO_EXPLICIT_TRX_ROUND = 1; + /** * Run the job * @return bool Success @@ -51,4 +54,55 @@ interface RunnableJob extends IJobSpecification { * @return mixed|null The prior field value; null if missing */ public function setMetadata( $field, $value ); + + /** + * @param int $flag JOB_* class constant + * @return bool + * @since 1.31 + */ + public function hasExecutionFlag( $flag ); + + /** + * @return string|null Id of the request that created this job. Follows + * jobs recursively, allowing to track the id of the request that started a + * job when jobs insert jobs which insert other jobs. + * @since 1.27 + */ + public function getRequestId(); + + /** + * @return bool Whether this job can be retried on failure by job runners + * @since 1.21 + */ + public function allowRetries(); + + /** + * @return int Number of actually "work items" handled in this job + * @see $wgJobBackoffThrottling + * @since 1.23 + */ + public function workItemCount(); + + /** + * @return int|null UNIX timestamp of when the job was runnable, or null + * @since 1.26 + */ + public function getReadyTimestamp(); + + /** + * Do any final cleanup after run(), deferred updates, and all DB commits happen + * @param bool $status Whether the job, its deferred updates, and DB commit all succeeded + * @since 1.27 + */ + public function tearDown( $status ); + + /** + * @return string + */ + public function getLastError(); + + /** + * @return string Debugging string describing the job + */ + public function toString(); } diff --git a/includes/jobqueue/jobs/CategoryMembershipChangeJob.php b/includes/jobqueue/jobs/CategoryMembershipChangeJob.php index 3aedc38f5f..cb4b051978 100644 --- a/includes/jobqueue/jobs/CategoryMembershipChangeJob.php +++ b/includes/jobqueue/jobs/CategoryMembershipChangeJob.php @@ -81,7 +81,7 @@ class CategoryMembershipChangeJob extends Job { public function run() { $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory(); $lb = $lbFactory->getMainLB(); - $dbw = $lb->getConnection( DB_MASTER ); + $dbw = $lb->getConnectionRef( DB_MASTER ); $this->ticket = $lbFactory->getEmptyTransactionTicket( __METHOD__ ); @@ -91,15 +91,15 @@ class CategoryMembershipChangeJob extends Job { return false; // deleted? } - // Cut down on the time spent in safeWaitForMasterPos() in the critical section - $dbr = $lb->getConnection( DB_REPLICA, [ 'recentchanges' ] ); - if ( !$lb->safeWaitForMasterPos( $dbr ) ) { + // Cut down on the time spent in waitForMasterPos() in the critical section + $dbr = $lb->getConnectionRef( DB_REPLICA, [ 'recentchanges' ] ); + if ( !$lb->waitForMasterPos( $dbr ) ) { $this->setLastError( "Timed out while pre-waiting for replica DB to catch up" ); return false; } // Use a named lock so that jobs for this page see each others' changes - $lockKey = "CategoryMembershipUpdates:{$page->getId()}"; + $lockKey = "{$dbw->getDomainID()}:CategoryMembershipChange:{$page->getId()}"; // per-wiki $scopedLock = $dbw->getScopedLockAndFlush( $lockKey, __METHOD__, 3 ); if ( !$scopedLock ) { $this->setLastError( "Could not acquire lock '$lockKey'" ); @@ -107,7 +107,7 @@ class CategoryMembershipChangeJob extends Job { } // Wait till replica DB is caught up so that jobs for this page see each others' changes - if ( !$lb->safeWaitForMasterPos( $dbr ) ) { + if ( !$lb->waitForMasterPos( $dbr ) ) { $this->setLastError( "Timed out while waiting for replica DB to catch up" ); return false; } diff --git a/includes/jobqueue/jobs/ClearUserWatchlistJob.php b/includes/jobqueue/jobs/ClearUserWatchlistJob.php index 0cb1a52d69..e373605281 100644 --- a/includes/jobqueue/jobs/ClearUserWatchlistJob.php +++ b/includes/jobqueue/jobs/ClearUserWatchlistJob.php @@ -40,24 +40,24 @@ class ClearUserWatchlistJob extends Job implements GenericParameterJob { $batchSize = $wgUpdateRowsPerQuery; $loadBalancer = MediaWikiServices::getInstance()->getDBLoadBalancer(); - $dbw = $loadBalancer->getConnection( DB_MASTER ); - $dbr = $loadBalancer->getConnection( DB_REPLICA, [ 'watchlist' ] ); + $dbw = $loadBalancer->getConnectionRef( DB_MASTER ); + $dbr = $loadBalancer->getConnectionRef( DB_REPLICA, [ 'watchlist' ] ); // Wait before lock to try to reduce time waiting in the lock. - if ( !$loadBalancer->safeWaitForMasterPos( $dbr ) ) { + if ( !$loadBalancer->waitForMasterPos( $dbr ) ) { $this->setLastError( 'Timed out waiting for replica to catch up before lock' ); return false; } // Use a named lock so that jobs for this user see each others' changes - $lockKey = "ClearUserWatchlistJob:$userId"; + $lockKey = "{{$dbw->getDomainID()}}:ClearUserWatchlist:$userId"; // per-wiki $scopedLock = $dbw->getScopedLockAndFlush( $lockKey, __METHOD__, 10 ); if ( !$scopedLock ) { $this->setLastError( "Could not acquire lock '$lockKey'" ); return false; } - if ( !$loadBalancer->safeWaitForMasterPos( $dbr ) ) { + if ( !$loadBalancer->waitForMasterPos( $dbr ) ) { $this->setLastError( 'Timed out waiting for replica to catch up within lock' ); return false; } diff --git a/includes/jobqueue/jobs/ClearWatchlistNotificationsJob.php b/includes/jobqueue/jobs/ClearWatchlistNotificationsJob.php index f53174a573..054718d900 100644 --- a/includes/jobqueue/jobs/ClearWatchlistNotificationsJob.php +++ b/includes/jobqueue/jobs/ClearWatchlistNotificationsJob.php @@ -51,7 +51,7 @@ class ClearWatchlistNotificationsJob extends Job implements GenericParameterJob $lbFactory = $services->getDBLoadBalancerFactory(); $rowsPerQuery = $services->getMainConfig()->get( 'UpdateRowsPerQuery' ); - $dbw = $lbFactory->getMainLB()->getConnection( DB_MASTER ); + $dbw = $lbFactory->getMainLB()->getConnectionRef( DB_MASTER ); $ticket = $lbFactory->getEmptyTransactionTicket( __METHOD__ ); $timestamp = $this->params['timestamp'] ?? null; if ( $timestamp === null ) { diff --git a/includes/jobqueue/jobs/RefreshLinksJob.php b/includes/jobqueue/jobs/RefreshLinksJob.php index 89ecb0ee92..b4046a61bc 100644 --- a/includes/jobqueue/jobs/RefreshLinksJob.php +++ b/includes/jobqueue/jobs/RefreshLinksJob.php @@ -22,6 +22,8 @@ */ use MediaWiki\MediaWikiServices; use MediaWiki\Revision\RevisionRecord; +use MediaWiki\Revision\RevisionRenderer; +use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface; /** * Job to update link tables for pages @@ -37,10 +39,8 @@ use MediaWiki\Revision\RevisionRecord; * @ingroup JobQueue */ class RefreshLinksJob extends Job { - /** @var float Cache parser output when it takes this long to render */ - const PARSE_THRESHOLD_SEC = 1.0; /** @var int Lag safety margin when comparing root job times to last-refresh times */ - const CLOCK_FUDGE = 10; + const NORMAL_MAX_LAG = 10; /** @var int How many seconds to wait for replica DBs to catch up */ const LAG_WAIT_TIMEOUT = 15; @@ -54,7 +54,9 @@ class RefreshLinksJob extends Job { !( isset( $params['pages'] ) && count( $params['pages'] ) != 1 ) ); $this->params += [ 'causeAction' => 'unknown', 'causeAgent' => 'unknown' ]; - // This will control transaction rounds in order to run DataUpdates + // Tell JobRunner to not automatically wrap run() in a transaction round. + // Each runForTitle() call will manage its own rounds in order to run DataUpdates + // and to avoid contention as well. $this->executionFlags |= self::JOB_NO_EXPLICIT_TRX_ROUND; } @@ -83,21 +85,21 @@ class RefreshLinksJob extends Job { } function run() { - global $wgUpdateRowsPerJob; - $ok = true; + // Job to update all (or a range of) backlink pages for a page if ( !empty( $this->params['recursive'] ) ) { + $services = MediaWikiServices::getInstance(); // When the base job branches, wait for the replica DBs to catch up to the master. // From then on, we know that any template changes at the time the base job was // enqueued will be reflected in backlink page parses when the leaf jobs run. if ( !isset( $this->params['range'] ) ) { - $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory(); + $lbFactory = $services->getDBLoadBalancerFactory(); if ( !$lbFactory->waitForReplication( [ - 'domain' => $lbFactory->getLocalDomainID(), - 'timeout' => self::LAG_WAIT_TIMEOUT + 'domain' => $lbFactory->getLocalDomainID(), + 'timeout' => self::LAG_WAIT_TIMEOUT ] ) ) { // only try so hard - $stats = MediaWikiServices::getInstance()->getStatsdDataFactory(); + $stats = $services->getStatsdDataFactory(); $stats->increment( 'refreshlinks.lag_wait_failed' ); } } @@ -111,7 +113,7 @@ class RefreshLinksJob extends Job { // jobs and possibly a recursive RefreshLinks job for the rest of the backlinks $jobs = BacklinkJobUtils::partitionBacklinkJob( $this, - $wgUpdateRowsPerJob, + $services->getMainConfig()->get( 'UpdateRowsPerJob' ), 1, // job-per-title [ 'params' => $extraParams ] ); @@ -121,7 +123,7 @@ class RefreshLinksJob extends Job { foreach ( $this->params['pages'] as list( $ns, $dbKey ) ) { $title = Title::makeTitleSafe( $ns, $dbKey ); if ( $title ) { - $this->runForTitle( $title ); + $ok = $this->runForTitle( $title ) && $ok; } else { $ok = false; $this->setLastError( "Invalid title ($ns,$dbKey)." ); @@ -129,7 +131,7 @@ class RefreshLinksJob extends Job { } // Job to update link tables for a given title } else { - $this->runForTitle( $this->title ); + $ok = $this->runForTitle( $this->title ); } return $ok; @@ -142,139 +144,235 @@ class RefreshLinksJob extends Job { protected function runForTitle( Title $title ) { $services = MediaWikiServices::getInstance(); $stats = $services->getStatsdDataFactory(); - $lbFactory = $services->getDBLoadBalancerFactory(); - $revisionStore = $services->getRevisionStore(); $renderer = $services->getRevisionRenderer(); + $parserCache = $services->getParserCache(); + $lbFactory = $services->getDBLoadBalancerFactory(); $ticket = $lbFactory->getEmptyTransactionTicket( __METHOD__ ); - $lbFactory->beginMasterChanges( __METHOD__ ); - + // Load the page from the master DB $page = WikiPage::factory( $title ); $page->loadPageData( WikiPage::READ_LATEST ); + // Serialize link update job by page ID so they see each others' changes. + // The page ID and latest revision ID will be queried again after the lock + // is acquired to bail if they are changed from that of loadPageData() above. // Serialize links updates by page ID so they see each others' changes - $dbw = $lbFactory->getMainLB()->getConnection( DB_MASTER ); + $dbw = $lbFactory->getMainLB()->getConnectionRef( DB_MASTER ); /** @noinspection PhpUnusedLocalVariableInspection */ $scopedLock = LinksUpdate::acquirePageLock( $dbw, $page->getId(), 'job' ); if ( $scopedLock === null ) { - $lbFactory->commitMasterChanges( __METHOD__ ); - // Another job is already updating the page, likely for an older revision (T170596). + // Another job is already updating the page, likely for a prior revision (T170596) $this->setLastError( 'LinksUpdate already running for this page, try again later.' ); + $stats->increment( 'refreshlinks.lock_failure' ); + + return false; + } + + if ( $this->isAlreadyRefreshed( $page ) ) { + $stats->increment( 'refreshlinks.update_skipped' ); + + return true; + } + + // Parse during a fresh transaction round for better read consistency + $lbFactory->beginMasterChanges( __METHOD__ ); + $output = $this->getParserOutput( $renderer, $parserCache, $page, $stats ); + $options = $this->getDataUpdateOptions(); + $lbFactory->commitMasterChanges( __METHOD__ ); + + if ( !$output ) { + return false; // raced out? + } + + // Tell DerivedPageDataUpdater to use this parser output + $options['known-revision-output'] = $output; + // Execute corresponding DataUpdates immediately + $page->doSecondaryDataUpdates( $options ); + InfoAction::invalidateCache( $title ); + + // Commit any writes here in case this method is called in a loop. + // In that case, the scoped lock will fail to be acquired. + $lbFactory->commitAndWaitForReplication( __METHOD__, $ticket ); + + return true; + } + + /** + * @param WikiPage $page + * @return bool Whether something updated the backlinks with data newer than this job + */ + private function isAlreadyRefreshed( WikiPage $page ) { + // Get the timestamp of the change that triggered this job + $rootTimestamp = $this->params['rootJobTimestamp'] ?? null; + if ( $rootTimestamp === null ) { return false; } - // Get the latest ID *after* acquirePageLock() flushed the transaction. + + if ( !empty( $this->params['isOpportunistic'] ) ) { + // Neither clock skew nor DB snapshot/replica DB lag matter much for + // such updates; focus on reusing the (often recently updated) cache + $lagAwareTimestamp = $rootTimestamp; + } else { + // For transclusion updates, the template changes must be reflected + $lagAwareTimestamp = wfTimestamp( + TS_MW, + wfTimestamp( TS_UNIX, $rootTimestamp ) + self::NORMAL_MAX_LAG + ); + } + + return ( $page->getLinksTimestamp() > $lagAwareTimestamp ); + } + + /** + * Get the parser output if the page is unchanged from what was loaded in $page + * + * @param RevisionRenderer $renderer + * @param ParserCache $parserCache + * @param WikiPage $page Page already loaded with READ_LATEST + * @param StatsdDataFactoryInterface $stats + * @return ParserOutput|null Combined output for all slots; might only contain metadata + */ + private function getParserOutput( + RevisionRenderer $renderer, + ParserCache $parserCache, + WikiPage $page, + StatsdDataFactoryInterface $stats + ) { + $revision = $this->getCurrentRevisionIfUnchanged( $page, $stats ); + if ( !$revision ) { + return null; // race condition? + } + + $cachedOutput = $this->getParserOutputFromCache( $parserCache, $page, $revision, $stats ); + if ( $cachedOutput ) { + return $cachedOutput; + } + + $renderedRevision = $renderer->getRenderedRevision( + $revision, + $page->makeParserOptions( 'canonical' ), + null, + [ 'audience' => $revision::RAW ] + ); + + $parseTimestamp = wfTimestampNow(); // timestamp that parsing started + $output = $renderedRevision->getRevisionParserOutput( [ 'generate-html' => false ] ); + $output->setCacheTime( $parseTimestamp ); // notify LinksUpdate::doUpdate() + + return $output; + } + + /** + * Get the current revision record if it is unchanged from what was loaded in $page + * + * @param WikiPage $page Page already loaded with READ_LATEST + * @param StatsdDataFactoryInterface $stats + * @return RevisionRecord|null The same instance that $page->getRevisionRecord() uses + */ + private function getCurrentRevisionIfUnchanged( + WikiPage $page, + StatsdDataFactoryInterface $stats + ) { + $title = $page->getTitle(); + // Get the latest ID since acquirePageLock() in runForTitle() flushed the transaction. // This is used to detect edits/moves after loadPageData() but before the scope lock. - // The works around the chicken/egg problem of determining the scope lock key. + // The works around the chicken/egg problem of determining the scope lock key name. $latest = $title->getLatestRevID( Title::GAID_FOR_UPDATE ); - if ( !empty( $this->params['triggeringRevisionId'] ) ) { - // Fetch the specified revision; lockAndGetLatest() below detects if the page - // was edited since and aborts in order to avoid corrupting the link tables - $revision = $revisionStore->getRevisionById( - (int)$this->params['triggeringRevisionId'], - Revision::READ_LATEST - ); - } else { - // Fetch current revision; READ_LATEST reduces lockAndGetLatest() check failures - $revision = $revisionStore->getRevisionByTitle( $title, 0, Revision::READ_LATEST ); + $triggeringRevisionId = $this->params['triggeringRevisionId'] ?? null; + if ( $triggeringRevisionId && $triggeringRevisionId !== $latest ) { + // This job is obsolete and one for the latest revision will handle updates + $stats->increment( 'refreshlinks.rev_not_current' ); + $this->setLastError( "Revision $triggeringRevisionId is not current" ); + + return null; } + // Load the current revision. Note that $page should have loaded with READ_LATEST. + // This instance will be reused in WikiPage::doSecondaryDataUpdates() later on. + $revision = $page->getRevisionRecord(); if ( !$revision ) { - $lbFactory->commitMasterChanges( __METHOD__ ); $stats->increment( 'refreshlinks.rev_not_found' ); $this->setLastError( "Revision not found for {$title->getPrefixedDBkey()}" ); - return false; // just deleted? - } elseif ( $revision->getId() != $latest || $revision->getPageId() !== $page->getId() ) { - $lbFactory->commitMasterChanges( __METHOD__ ); + + return null; // just deleted? + } elseif ( $revision->getId() !== $latest || $revision->getPageId() !== $page->getId() ) { // Do not clobber over newer updates with older ones. If all jobs where FIFO and // serialized, it would be OK to update links based on older revisions since it // would eventually get to the latest. Since that is not the case (by design), // only update the link tables to a state matching the current revision's output. $stats->increment( 'refreshlinks.rev_not_current' ); $this->setLastError( "Revision {$revision->getId()} is not current" ); - return false; + + return null; } - $parserOutput = false; - $parserOptions = $page->makeParserOptions( 'canonical' ); + return $revision; + } + + /** + * Get the parser output from cache if it reflects the change that triggered this job + * + * @param ParserCache $parserCache + * @param WikiPage $page + * @param RevisionRecord $currentRevision + * @param StatsdDataFactoryInterface $stats + * @return ParserOutput|null + */ + private function getParserOutputFromCache( + ParserCache $parserCache, + WikiPage $page, + RevisionRecord $currentRevision, + StatsdDataFactoryInterface $stats + ) { + $cachedOutput = null; // If page_touched changed after this root job, then it is likely that // any views of the pages already resulted in re-parses which are now in // cache. The cache can be reused to avoid expensive parsing in some cases. - if ( isset( $this->params['rootJobTimestamp'] ) ) { + $rootTimestamp = $this->params['rootJobTimestamp'] ?? null; + if ( $rootTimestamp !== null ) { $opportunistic = !empty( $this->params['isOpportunistic'] ); - - $skewedTimestamp = $this->params['rootJobTimestamp']; if ( $opportunistic ) { - // Neither clock skew nor DB snapshot/replica DB lag matter much for such - // updates; focus on reusing the (often recently updated) cache + // Neither clock skew nor DB snapshot/replica DB lag matter much for + // such updates; focus on reusing the (often recently updated) cache + $lagAwareTimestamp = $rootTimestamp; } else { // For transclusion updates, the template changes must be reflected - $skewedTimestamp = wfTimestamp( TS_MW, - wfTimestamp( TS_UNIX, $skewedTimestamp ) + self::CLOCK_FUDGE + $lagAwareTimestamp = wfTimestamp( + TS_MW, + wfTimestamp( TS_UNIX, $rootTimestamp ) + self::NORMAL_MAX_LAG ); } - if ( $page->getLinksTimestamp() > $skewedTimestamp ) { - $lbFactory->commitMasterChanges( __METHOD__ ); - // Something already updated the backlinks since this job was made - $stats->increment( 'refreshlinks.update_skipped' ); - return true; - } - - if ( $page->getTouched() >= $this->params['rootJobTimestamp'] || $opportunistic ) { - // Cache is suspected to be up-to-date. As long as the cache rev ID matches - // and it reflects the job's triggering change, then it is usable. - $parserOutput = $services->getParserCache()->getDirty( $page, $parserOptions ); - if ( !$parserOutput - || $parserOutput->getCacheRevisionId() != $revision->getId() - || $parserOutput->getCacheTime() < $skewedTimestamp + if ( $page->getTouched() >= $rootTimestamp || $opportunistic ) { + // Cache is suspected to be up-to-date so it's worth the I/O of checking. + // As long as the cache rev ID matches the current rev ID and it reflects + // the job's triggering change, then it is usable. + $parserOptions = $page->makeParserOptions( 'canonical' ); + $output = $parserCache->getDirty( $page, $parserOptions ); + if ( + $output && + $output->getCacheRevisionId() == $currentRevision->getId() && + $output->getCacheTime() >= $lagAwareTimestamp ) { - $parserOutput = false; // too stale + $cachedOutput = $output; } } } - // Fetch the current revision and parse it if necessary... - if ( $parserOutput ) { + if ( $cachedOutput ) { $stats->increment( 'refreshlinks.parser_cached' ); } else { - $start = microtime( true ); - - $checkCache = $page->shouldCheckParserCache( $parserOptions, $revision->getId() ); - - // Revision ID must be passed to the parser output to get revision variables correct - $renderedRevision = $renderer->getRenderedRevision( - $revision, - $parserOptions, - null, - [ - // use master, for consistency with the getRevisionByTitle call above. - 'use-master' => true, - // bypass audience checks, since we know that this is the current revision. - 'audience' => RevisionRecord::RAW - ] - ); - $parserOutput = $renderedRevision->getRevisionParserOutput( - // HTML is only needed if the output is to be placed in the parser cache - [ 'generate-html' => $checkCache ] - ); - - // If it took a long time to render, then save this back to the cache to avoid - // wasted CPU by other apaches or job runners. We don't want to always save to - // cache as this can cause high cache I/O and LRU churn when a template changes. - $elapsed = microtime( true ) - $start; - - $parseThreshold = $this->params['parseThreshold'] ?? self::PARSE_THRESHOLD_SEC; - - if ( $checkCache && $elapsed >= $parseThreshold && $parserOutput->isCacheable() ) { - $ctime = wfTimestamp( TS_MW, (int)$start ); // cache time - $services->getParserCache()->save( - $parserOutput, $page, $parserOptions, $ctime, $revision->getId() - ); - } $stats->increment( 'refreshlinks.parser_uncached' ); } + return $cachedOutput; + } + + /** + * @return array + */ + private function getDataUpdateOptions() { $options = [ 'recursive' => !empty( $this->params['useRecursiveLinksUpdate'] ), // Carry over cause so the update can do extra logging @@ -291,17 +389,7 @@ class RefreshLinksJob extends Job { } } - $lbFactory->commitMasterChanges( __METHOD__ ); - - $page->doSecondaryDataUpdates( $options ); - - InfoAction::invalidateCache( $title ); - - // Commit any writes here in case this method is called in a loop. - // In that case, the scoped lock will fail to be acquired. - $lbFactory->commitAndWaitForReplication( __METHOD__, $ticket ); - - return true; + return $options; } public function getDeduplicationInfo() { diff --git a/includes/language/LanguageCode.php b/includes/language/LanguageCode.php new file mode 100644 index 0000000000..7d954d3803 --- /dev/null +++ b/includes/language/LanguageCode.php @@ -0,0 +1,204 @@ + 'gsw', // T25215 + 'bat-smg' => 'sgs', // T27522 + 'be-x-old' => 'be-tarask', // T11823 + 'fiu-vro' => 'vro', // T31186 + 'roa-rup' => 'rup', // T17988 + 'zh-classical' => 'lzh', // T30443 + 'zh-min-nan' => 'nan', // T30442 + 'zh-yue' => 'yue', // T30441 + ]; + + /** + * Mapping of non-standard language codes used in MediaWiki to + * standardized BCP 47 codes. These are not deprecated (yet?): + * IANA may eventually recognize the subtag, in which case the `-x-` + * infix could be removed, or else we could rename the code in + * MediaWiki, in which case they'd move up to the above mapping + * of deprecated codes. + * + * As a rule, we preserve all distinctions made by MediaWiki + * internally. For example, `de-formal` becomes `de-x-formal` + * instead of just `de` because MediaWiki distinguishes `de-formal` + * from `de` (for example, for interface translations). Similarly, + * BCP 47 indicates that `kk-Cyrl` SHOULD not be used because it + * "typically does not add information", but in our case MediaWiki + * LanguageConverter distinguishes `kk` (render content in a mix of + * Kurdish variants) from `kk-Cyrl` (convert content to be uniformly + * Cyrillic). As the BCP 47 requirement is a SHOULD not a MUST, + * `kk-Cyrl` is a valid code, although some validators may emit + * a warning note. + * + * @var array Mapping from nonstandard MediaWiki-internal codes to + * BCP 47 codes + * + * @since 1.32 + * @see https://meta.wikimedia.org/wiki/Special_language_codes + * @see https://phabricator.wikimedia.org/T125073 + */ + private static $nonstandardLanguageCodeMapping = [ + // All codes returned by Language::fetchLanguageNames() validated + // against IANA registry at + // https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry + // with help of validator at + // http://schneegans.de/lv/ + 'cbk-zam' => 'cbk', // T124657 + 'de-formal' => 'de-x-formal', + 'eml' => 'egl', // T36217 + 'en-rtl' => 'en-x-rtl', + 'es-formal' => 'es-x-formal', + 'hu-formal' => 'hu-x-formal', + 'map-bms' => 'jv-x-bms', // [[en:Banyumasan_dialect]] T125073 + 'mo' => 'ro-Cyrl-MD', // T125073 + 'nrm' => 'nrf', // [[en:Norman_language]] T25216 + 'nl-informal' => 'nl-x-informal', + 'roa-tara' => 'nap-x-tara', // [[en:Tarantino_dialect]] + 'simple' => 'en-simple', + 'sr-ec' => 'sr-Cyrl', // T117845 + 'sr-el' => 'sr-Latn', // T117845 + + // Although these next codes aren't *wrong* per se, including + // both the script and the country code helps compatibility with + // other BCP 47 users. Note that MW also uses `zh-Hans`/`zh-Hant`, + // without a country code, and those should be left alone. + // (See $variantfallbacks in LanguageZh.php for Hans/Hant id.) + 'zh-cn' => 'zh-Hans-CN', + 'zh-sg' => 'zh-Hans-SG', + 'zh-my' => 'zh-Hans-MY', + 'zh-tw' => 'zh-Hant-TW', + 'zh-hk' => 'zh-Hant-HK', + 'zh-mo' => 'zh-Hant-MO', + ]; + + /** + * Returns a mapping of deprecated language codes that were used in previous + * versions of MediaWiki to up-to-date, current language codes. + * + * This array is merged into $wgDummyLanguageCodes in Setup.php, along with + * the fake language codes 'qqq' and 'qqx', which are used internally by + * MediaWiki's localisation system. + * + * @return string[] + * + * @since 1.29 + */ + public static function getDeprecatedCodeMapping() { + return self::$deprecatedLanguageCodeMapping; + } + + /** + * Returns a mapping of non-standard language codes used by + * (current and previous version of) MediaWiki, mapped to standard + * BCP 47 names. + * + * This array is exported to JavaScript to ensure + * mediawiki.language.bcp47 stays in sync with LanguageCode::bcp47(). + * + * @return string[] + * + * @since 1.32 + */ + public static function getNonstandardLanguageCodeMapping() { + $result = []; + foreach ( self::$deprecatedLanguageCodeMapping as $code => $ignore ) { + $result[$code] = self::bcp47( $code ); + } + foreach ( self::$nonstandardLanguageCodeMapping as $code => $ignore ) { + $result[$code] = self::bcp47( $code ); + } + return $result; + } + + /** + * Replace deprecated language codes that were used in previous + * versions of MediaWiki to up-to-date, current language codes. + * Other values will returned unchanged. + * + * @param string $code Old language code + * @return string New language code + * + * @since 1.30 + */ + public static function replaceDeprecatedCodes( $code ) { + return self::$deprecatedLanguageCodeMapping[$code] ?? $code; + } + + /** + * Get the normalised IETF language tag + * See unit test for examples. + * See mediawiki.language.bcp47 for the JavaScript implementation. + * + * @param string $code The language code. + * @return string A language code complying with BCP 47 standards. + * + * @since 1.31 + */ + public static function bcp47( $code ) { + $code = self::replaceDeprecatedCodes( strtolower( $code ) ); + if ( isset( self::$nonstandardLanguageCodeMapping[$code] ) ) { + $code = self::$nonstandardLanguageCodeMapping[$code]; + } + $codeSegment = explode( '-', $code ); + $codeBCP = []; + foreach ( $codeSegment as $segNo => $seg ) { + // when previous segment is x, it is a private segment and should be lc + if ( $segNo > 0 && strtolower( $codeSegment[( $segNo - 1 )] ) == 'x' ) { + $codeBCP[$segNo] = strtolower( $seg ); + // ISO 3166 country code + } elseif ( ( strlen( $seg ) == 2 ) && ( $segNo > 0 ) ) { + $codeBCP[$segNo] = strtoupper( $seg ); + // ISO 15924 script code + } elseif ( ( strlen( $seg ) == 4 ) && ( $segNo > 0 ) ) { + $codeBCP[$segNo] = ucfirst( strtolower( $seg ) ); + // Use lowercase for other cases + } else { + $codeBCP[$segNo] = strtolower( $seg ); + } + } + $langCode = implode( '-', $codeBCP ); + return $langCode; + } +} diff --git a/includes/language/Message.php b/includes/language/Message.php new file mode 100644 index 0000000000..12007faf87 --- /dev/null +++ b/includes/language/Message.php @@ -0,0 +1,1398 @@ +text() + * ); + * @endcode + * + * A Message instance can be passed parameters after it has been constructed, + * use the params() method to do so: + * + * @code + * wfMessage( 'welcome-to' ) + * ->params( $wgSitename ) + * ->text(); + * @endcode + * + * {{GRAMMAR}} and friends work correctly: + * + * @code + * wfMessage( 'are-friends', + * $user, $friend + * ); + * wfMessage( 'bad-message' ) + * ->rawParams( '' ) + * ->escaped(); + * @endcode + * + * @section message_language Changing language: + * + * Messages can be requested in a different language or in whatever current + * content language is being used. The methods are: + * - Message->inContentLanguage() + * - Message->inLanguage() + * + * Sometimes the message text ends up in the database, so content language is + * needed: + * + * @code + * wfMessage( 'file-log', + * $user, $filename + * )->inContentLanguage()->text(); + * @endcode + * + * Checking whether a message exists: + * + * @code + * wfMessage( 'mysterious-message' )->exists() + * // returns a boolean whether the 'mysterious-message' key exist. + * @endcode + * + * If you want to use a different language: + * + * @code + * $userLanguage = $user->getOption( 'language' ); + * wfMessage( 'email-header' ) + * ->inLanguage( $userLanguage ) + * ->plain(); + * @endcode + * + * @note You can parse the text only in the content or interface languages + * + * @section message_compare_old Comparison with old wfMsg* functions: + * + * Use full parsing: + * + * @code + * // old style: + * wfMsgExt( 'key', [ 'parseinline' ], 'apple' ); + * // new style: + * wfMessage( 'key', 'apple' )->parse(); + * @endcode + * + * Parseinline is used because it is more useful when pre-building HTML. + * In normal use it is better to use OutputPage::(add|wrap)WikiMsg. + * + * Places where HTML cannot be used. {{-transformation is done. + * @code + * // old style: + * wfMsgExt( 'key', [ 'parsemag' ], 'apple', 'pear' ); + * // new style: + * wfMessage( 'key', 'apple', 'pear' )->text(); + * @endcode + * + * Shortcut for escaping the message too, similar to wfMsgHTML(), but + * parameters are not replaced after escaping by default. + * @code + * $escaped = wfMessage( 'key' ) + * ->rawParams( 'apple' ) + * ->escaped(); + * @endcode + * + * @section message_appendix Appendix: + * + * @todo + * - test, can we have tests? + * - this documentation needs to be extended + * + * @see https://www.mediawiki.org/wiki/WfMessage() + * @see https://www.mediawiki.org/wiki/New_messages_API + * @see https://www.mediawiki.org/wiki/Localisation + * + * @since 1.17 + */ +class Message implements MessageSpecifier, Serializable { + /** Use message text as-is */ + const FORMAT_PLAIN = 'plain'; + /** Use normal wikitext -> HTML parsing (the result will be wrapped in a block-level HTML tag) */ + const FORMAT_BLOCK_PARSE = 'block-parse'; + /** Use normal wikitext -> HTML parsing but strip the block-level wrapper */ + const FORMAT_PARSE = 'parse'; + /** Transform {{..}} constructs but don't transform to HTML */ + const FORMAT_TEXT = 'text'; + /** Transform {{..}} constructs, HTML-escape the result */ + const FORMAT_ESCAPED = 'escaped'; + + /** + * Mapping from Message::listParam() types to Language methods. + * @var array + */ + protected static $listTypeMap = [ + 'comma' => 'commaList', + 'semicolon' => 'semicolonList', + 'pipe' => 'pipeList', + 'text' => 'listToText', + ]; + + /** + * In which language to get this message. True, which is the default, + * means the current user language, false content language. + * + * @var bool + */ + protected $interface = true; + + /** + * In which language to get this message. Overrides the $interface setting. + * + * @var Language|bool Explicit language object, or false for user language + */ + protected $language = false; + + /** + * @var string The message key. If $keysToTry has more than one element, + * this may change to one of the keys to try when fetching the message text. + */ + protected $key; + + /** + * @var string[] List of keys to try when fetching the message. + */ + protected $keysToTry; + + /** + * @var array List of parameters which will be substituted into the message. + */ + protected $parameters = []; + + /** + * @var string + * @deprecated + */ + protected $format = 'parse'; + + /** + * @var bool Whether database can be used. + */ + protected $useDatabase = true; + + /** + * @var Title Title object to use as context. + */ + protected $title = null; + + /** + * @var Content Content object representing the message. + */ + protected $content = null; + + /** + * @var string + */ + protected $message; + + /** + * @since 1.17 + * @param string|string[]|MessageSpecifier $key Message key, or array of + * message keys to try and use the first non-empty message for, or a + * MessageSpecifier to copy from. + * @param array $params Message parameters. + * @param Language|null $language [optional] Language to use (defaults to current user language). + * @throws InvalidArgumentException + */ + public function __construct( $key, $params = [], Language $language = null ) { + if ( $key instanceof MessageSpecifier ) { + if ( $params ) { + throw new InvalidArgumentException( + '$params must be empty if $key is a MessageSpecifier' + ); + } + $params = $key->getParams(); + $key = $key->getKey(); + } + + if ( !is_string( $key ) && !is_array( $key ) ) { + throw new InvalidArgumentException( '$key must be a string or an array' ); + } + + $this->keysToTry = (array)$key; + + if ( empty( $this->keysToTry ) ) { + throw new InvalidArgumentException( '$key must not be an empty list' ); + } + + $this->key = reset( $this->keysToTry ); + + $this->parameters = array_values( $params ); + // User language is only resolved in getLanguage(). This helps preserve the + // semantic intent of "user language" across serialize() and unserialize(). + $this->language = $language ?: false; + } + + /** + * @see Serializable::serialize() + * @since 1.26 + * @return string + */ + public function serialize() { + return serialize( [ + 'interface' => $this->interface, + 'language' => $this->language ? $this->language->getCode() : false, + 'key' => $this->key, + 'keysToTry' => $this->keysToTry, + 'parameters' => $this->parameters, + 'format' => $this->format, + 'useDatabase' => $this->useDatabase, + 'titlestr' => $this->title ? $this->title->getFullText() : null, + ] ); + } + + /** + * @see Serializable::unserialize() + * @since 1.26 + * @param string $serialized + */ + public function unserialize( $serialized ) { + $data = unserialize( $serialized ); + if ( !is_array( $data ) ) { + throw new InvalidArgumentException( __METHOD__ . ': Invalid serialized data' ); + } + + $this->interface = $data['interface']; + $this->key = $data['key']; + $this->keysToTry = $data['keysToTry']; + $this->parameters = $data['parameters']; + $this->format = $data['format']; + $this->useDatabase = $data['useDatabase']; + $this->language = $data['language'] ? Language::factory( $data['language'] ) : false; + + if ( isset( $data['titlestr'] ) ) { + $this->title = Title::newFromText( $data['titlestr'] ); + } elseif ( isset( $data['title'] ) && $data['title'] instanceof Title ) { + // Old serializations from before December 2018 + $this->title = $data['title']; + } else { + $this->title = null; // Explicit for sanity + } + } + + /** + * @since 1.24 + * + * @return bool True if this is a multi-key message, that is, if the key provided to the + * constructor was a fallback list of keys to try. + */ + public function isMultiKey() { + return count( $this->keysToTry ) > 1; + } + + /** + * @since 1.24 + * + * @return string[] The list of keys to try when fetching the message text, + * in order of preference. + */ + public function getKeysToTry() { + return $this->keysToTry; + } + + /** + * Returns the message key. + * + * If a list of multiple possible keys was supplied to the constructor, this method may + * return any of these keys. After the message has been fetched, this method will return + * the key that was actually used to fetch the message. + * + * @since 1.21 + * + * @return string + */ + public function getKey() { + return $this->key; + } + + /** + * Returns the message parameters. + * + * @since 1.21 + * + * @return array + */ + public function getParams() { + return $this->parameters; + } + + /** + * Returns the message format. + * + * @since 1.21 + * + * @return string + * @deprecated since 1.29 formatting is not stateful + */ + public function getFormat() { + wfDeprecated( __METHOD__, '1.29' ); + return $this->format; + } + + /** + * Returns the Language of the Message. + * + * @since 1.23 + * + * @return Language + */ + public function getLanguage() { + // Defaults to false which means current user language + return $this->language ?: RequestContext::getMain()->getLanguage(); + } + + /** + * Factory function that is just wrapper for the real constructor. It is + * intended to be used instead of the real constructor, because it allows + * chaining method calls, while new objects don't. + * + * @since 1.17 + * + * @param string|string[]|MessageSpecifier $key + * @param mixed $param,... Parameters as strings. + * + * @return Message + */ + public static function newFromKey( $key /*...*/ ) { + $params = func_get_args(); + array_shift( $params ); + return new self( $key, $params ); + } + + /** + * Transform a MessageSpecifier or a primitive value used interchangeably with + * specifiers (a message key string, or a key + params array) into a proper Message. + * + * Also accepts a MessageSpecifier inside an array: that's not considered a valid format + * but is an easy error to make due to how StatusValue stores messages internally. + * Further array elements are ignored in that case. + * + * @param string|array|MessageSpecifier $value + * @return Message + * @throws InvalidArgumentException + * @since 1.27 + */ + public static function newFromSpecifier( $value ) { + $params = []; + if ( is_array( $value ) ) { + $params = $value; + $value = array_shift( $params ); + } + + if ( $value instanceof Message ) { // Message, RawMessage, ApiMessage, etc + $message = clone $value; + } elseif ( $value instanceof MessageSpecifier ) { + $message = new Message( $value ); + } elseif ( is_string( $value ) ) { + $message = new Message( $value, $params ); + } else { + throw new InvalidArgumentException( __METHOD__ . ': invalid argument type ' + . gettype( $value ) ); + } + + return $message; + } + + /** + * Factory function accepting multiple message keys and returning a message instance + * for the first message which is non-empty. If all messages are empty then an + * instance of the first message key is returned. + * + * @since 1.18 + * + * @param string|string[] $keys,... Message keys, or first argument as an array of all the + * message keys. + * + * @return Message + */ + public static function newFallbackSequence( /*...*/ ) { + $keys = func_get_args(); + if ( func_num_args() == 1 ) { + if ( is_array( $keys[0] ) ) { + // Allow an array to be passed as the first argument instead + $keys = array_values( $keys[0] ); + } else { + // Optimize a single string to not need special fallback handling + $keys = $keys[0]; + } + } + return new self( $keys ); + } + + /** + * Get a title object for a mediawiki message, where it can be found in the mediawiki namespace. + * The title will be for the current language, if the message key is in + * $wgForceUIMsgAsContentMsg it will be append with the language code (except content + * language), because Message::inContentLanguage will also return in user language. + * + * @see $wgForceUIMsgAsContentMsg + * @return Title + * @since 1.26 + */ + public function getTitle() { + global $wgForceUIMsgAsContentMsg; + + $contLang = MediaWikiServices::getInstance()->getContentLanguage(); + $lang = $this->getLanguage(); + $title = $this->key; + if ( + !$lang->equals( $contLang ) + && in_array( $this->key, (array)$wgForceUIMsgAsContentMsg ) + ) { + $title .= '/' . $lang->getCode(); + } + + return Title::makeTitle( + NS_MEDIAWIKI, $contLang->ucfirst( strtr( $title, ' ', '_' ) ) ); + } + + /** + * Adds parameters to the parameter list of this message. + * + * @since 1.17 + * + * @param mixed $args,... Parameters as strings or arrays from + * Message::numParam() and the like, or a single array of parameters. + * + * @return Message $this + */ + public function params( /*...*/ ) { + $args = func_get_args(); + + // If $args has only one entry and it's an array, then it's either a + // non-varargs call or it happens to be a call with just a single + // "special" parameter. Since the "special" parameters don't have any + // numeric keys, we'll test that to differentiate the cases. + if ( count( $args ) === 1 && isset( $args[0] ) && is_array( $args[0] ) ) { + if ( $args[0] === [] ) { + $args = []; + } else { + foreach ( $args[0] as $key => $value ) { + if ( is_int( $key ) ) { + $args = $args[0]; + break; + } + } + } + } + + $this->parameters = array_merge( $this->parameters, array_values( $args ) ); + return $this; + } + + /** + * Add parameters that are substituted after parsing or escaping. + * In other words the parsing process cannot access the contents + * of this type of parameter, and you need to make sure it is + * sanitized beforehand. The parser will see "$n", instead. + * + * @since 1.17 + * + * @param mixed $params,... Raw parameters as strings, or a single argument that is + * an array of raw parameters. + * + * @return Message $this + */ + public function rawParams( /*...*/ ) { + $params = func_get_args(); + if ( isset( $params[0] ) && is_array( $params[0] ) ) { + $params = $params[0]; + } + foreach ( $params as $param ) { + $this->parameters[] = self::rawParam( $param ); + } + return $this; + } + + /** + * Add parameters that are numeric and will be passed through + * Language::formatNum before substitution + * + * @since 1.18 + * + * @param mixed $param,... Numeric parameters, or a single argument that is + * an array of numeric parameters. + * + * @return Message $this + */ + public function numParams( /*...*/ ) { + $params = func_get_args(); + if ( isset( $params[0] ) && is_array( $params[0] ) ) { + $params = $params[0]; + } + foreach ( $params as $param ) { + $this->parameters[] = self::numParam( $param ); + } + return $this; + } + + /** + * Add parameters that are durations of time and will be passed through + * Language::formatDuration before substitution + * + * @since 1.22 + * + * @param int|int[] $param,... Duration parameters, or a single argument that is + * an array of duration parameters. + * + * @return Message $this + */ + public function durationParams( /*...*/ ) { + $params = func_get_args(); + if ( isset( $params[0] ) && is_array( $params[0] ) ) { + $params = $params[0]; + } + foreach ( $params as $param ) { + $this->parameters[] = self::durationParam( $param ); + } + return $this; + } + + /** + * Add parameters that are expiration times and will be passed through + * Language::formatExpiry before substitution + * + * @since 1.22 + * + * @param string|string[] $param,... Expiry parameters, or a single argument that is + * an array of expiry parameters. + * + * @return Message $this + */ + public function expiryParams( /*...*/ ) { + $params = func_get_args(); + if ( isset( $params[0] ) && is_array( $params[0] ) ) { + $params = $params[0]; + } + foreach ( $params as $param ) { + $this->parameters[] = self::expiryParam( $param ); + } + return $this; + } + + /** + * Add parameters that are time periods and will be passed through + * Language::formatTimePeriod before substitution + * + * @since 1.22 + * + * @param int|int[] $param,... Time period parameters, or a single argument that is + * an array of time period parameters. + * + * @return Message $this + */ + public function timeperiodParams( /*...*/ ) { + $params = func_get_args(); + if ( isset( $params[0] ) && is_array( $params[0] ) ) { + $params = $params[0]; + } + foreach ( $params as $param ) { + $this->parameters[] = self::timeperiodParam( $param ); + } + return $this; + } + + /** + * Add parameters that are file sizes and will be passed through + * Language::formatSize before substitution + * + * @since 1.22 + * + * @param int|int[] $param,... Size parameters, or a single argument that is + * an array of size parameters. + * + * @return Message $this + */ + public function sizeParams( /*...*/ ) { + $params = func_get_args(); + if ( isset( $params[0] ) && is_array( $params[0] ) ) { + $params = $params[0]; + } + foreach ( $params as $param ) { + $this->parameters[] = self::sizeParam( $param ); + } + return $this; + } + + /** + * Add parameters that are bitrates and will be passed through + * Language::formatBitrate before substitution + * + * @since 1.22 + * + * @param int|int[] $param,... Bit rate parameters, or a single argument that is + * an array of bit rate parameters. + * + * @return Message $this + */ + public function bitrateParams( /*...*/ ) { + $params = func_get_args(); + if ( isset( $params[0] ) && is_array( $params[0] ) ) { + $params = $params[0]; + } + foreach ( $params as $param ) { + $this->parameters[] = self::bitrateParam( $param ); + } + return $this; + } + + /** + * Add parameters that are plaintext and will be passed through without + * the content being evaluated. Plaintext parameters are not valid as + * arguments to parser functions. This differs from self::rawParams in + * that the Message class handles escaping to match the output format. + * + * @since 1.25 + * + * @param string|string[] $param,... plaintext parameters, or a single argument that is + * an array of plaintext parameters. + * + * @return Message $this + */ + public function plaintextParams( /*...*/ ) { + $params = func_get_args(); + if ( isset( $params[0] ) && is_array( $params[0] ) ) { + $params = $params[0]; + } + foreach ( $params as $param ) { + $this->parameters[] = self::plaintextParam( $param ); + } + return $this; + } + + /** + * Set the language and the title from a context object + * + * @since 1.19 + * + * @param IContextSource $context + * + * @return Message $this + */ + public function setContext( IContextSource $context ) { + $this->inLanguage( $context->getLanguage() ); + $this->title( $context->getTitle() ); + $this->interface = true; + + return $this; + } + + /** + * Request the message in any language that is supported. + * + * As a side effect interface message status is unconditionally + * turned off. + * + * @since 1.17 + * @param Language|string $lang Language code or Language object. + * @return Message $this + * @throws MWException + */ + public function inLanguage( $lang ) { + $previousLanguage = $this->language; + + if ( $lang instanceof Language ) { + $this->language = $lang; + } elseif ( is_string( $lang ) ) { + if ( !$this->language instanceof Language || $this->language->getCode() != $lang ) { + $this->language = Language::factory( $lang ); + } + } elseif ( $lang instanceof StubUserLang ) { + $this->language = false; + } else { + $type = gettype( $lang ); + throw new MWException( __METHOD__ . " must be " + . "passed a String or Language object; $type given" + ); + } + + if ( $this->language !== $previousLanguage ) { + // The language has changed. Clear the message cache. + $this->message = null; + } + $this->interface = false; + return $this; + } + + /** + * Request the message in the wiki's content language, + * unless it is disabled for this message. + * + * @since 1.17 + * @see $wgForceUIMsgAsContentMsg + * + * @return Message $this + */ + public function inContentLanguage() { + global $wgForceUIMsgAsContentMsg; + if ( in_array( $this->key, (array)$wgForceUIMsgAsContentMsg ) ) { + return $this; + } + + $this->inLanguage( MediaWikiServices::getInstance()->getContentLanguage() ); + return $this; + } + + /** + * Allows manipulating the interface message flag directly. + * Can be used to restore the flag after setting a language. + * + * @since 1.20 + * + * @param bool $interface + * + * @return Message $this + */ + public function setInterfaceMessageFlag( $interface ) { + $this->interface = (bool)$interface; + return $this; + } + + /** + * Enable or disable database use. + * + * @since 1.17 + * + * @param bool $useDatabase + * + * @return Message $this + */ + public function useDatabase( $useDatabase ) { + $this->useDatabase = (bool)$useDatabase; + $this->message = null; + return $this; + } + + /** + * Set the Title object to use as context when transforming the message + * + * @since 1.18 + * + * @param Title $title + * + * @return Message $this + */ + public function title( $title ) { + $this->title = $title; + return $this; + } + + /** + * Returns the message as a Content object. + * + * @return Content + */ + public function content() { + if ( !$this->content ) { + $this->content = new MessageContent( $this ); + } + + return $this->content; + } + + /** + * Returns the message parsed from wikitext to HTML. + * + * @since 1.17 + * + * @param string|null $format One of the FORMAT_* constants. Null means use whatever was used + * the last time (this is for B/C and should be avoided). + * + * @return string HTML + * @suppress SecurityCheck-DoubleEscaped phan false positive + */ + public function toString( $format = null ) { + if ( $format === null ) { + $ex = new LogicException( __METHOD__ . ' using implicit format: ' . $this->format ); + LoggerFactory::getInstance( 'message-format' )->warning( + $ex->getMessage(), [ 'exception' => $ex, 'format' => $this->format, 'key' => $this->key ] ); + $format = $this->format; + } + $string = $this->fetchMessage(); + + if ( $string === false ) { + // Err on the side of safety, ensure that the output + // is always html safe in the event the message key is + // missing, since in that case its highly likely the + // message key is user-controlled. + // '⧼' is used instead of '<' to side-step any + // double-escaping issues. + // (Keep synchronised with mw.Message#toString in JS.) + return '⧼' . htmlspecialchars( $this->key ) . '⧽'; + } + + # Replace $* with a list of parameters for &uselang=qqx. + if ( strpos( $string, '$*' ) !== false ) { + $paramlist = ''; + if ( $this->parameters !== [] ) { + $paramlist = ': $' . implode( ', $', range( 1, count( $this->parameters ) ) ); + } + $string = str_replace( '$*', $paramlist, $string ); + } + + # Replace parameters before text parsing + $string = $this->replaceParameters( $string, 'before', $format ); + + # Maybe transform using the full parser + if ( $format === self::FORMAT_PARSE ) { + $string = $this->parseText( $string ); + $string = Parser::stripOuterParagraph( $string ); + } elseif ( $format === self::FORMAT_BLOCK_PARSE ) { + $string = $this->parseText( $string ); + } elseif ( $format === self::FORMAT_TEXT ) { + $string = $this->transformText( $string ); + } elseif ( $format === self::FORMAT_ESCAPED ) { + $string = $this->transformText( $string ); + $string = htmlspecialchars( $string, ENT_QUOTES, 'UTF-8', false ); + } + + # Raw parameter replacement + $string = $this->replaceParameters( $string, 'after', $format ); + + return $string; + } + + /** + * Magic method implementation of the above (for PHP >= 5.2.0), so we can do, eg: + * $foo = new Message( $key ); + * $string = "$foo"; + * + * @since 1.18 + * + * @return string + */ + public function __toString() { + // PHP doesn't allow __toString to throw exceptions and will + // trigger a fatal error if it does. So, catch any exceptions. + + try { + return $this->toString( self::FORMAT_PARSE ); + } catch ( Exception $ex ) { + try { + trigger_error( "Exception caught in " . __METHOD__ . " (message " . $this->key . "): " + . $ex, E_USER_WARNING ); + } catch ( Exception $ex ) { + // Doh! Cause a fatal error after all? + } + + return '⧼' . htmlspecialchars( $this->key ) . '⧽'; + } + } + + /** + * Fully parse the text from wikitext to HTML. + * + * @since 1.17 + * + * @return string Parsed HTML. + */ + public function parse() { + $this->format = self::FORMAT_PARSE; + return $this->toString( self::FORMAT_PARSE ); + } + + /** + * Returns the message text. {{-transformation is done. + * + * @since 1.17 + * + * @return string Unescaped message text. + */ + public function text() { + $this->format = self::FORMAT_TEXT; + return $this->toString( self::FORMAT_TEXT ); + } + + /** + * Returns the message text as-is, only parameters are substituted. + * + * @since 1.17 + * + * @return string Unescaped untransformed message text. + */ + public function plain() { + $this->format = self::FORMAT_PLAIN; + return $this->toString( self::FORMAT_PLAIN ); + } + + /** + * Returns the parsed message text which is always surrounded by a block element. + * + * @since 1.17 + * + * @return string HTML + */ + public function parseAsBlock() { + $this->format = self::FORMAT_BLOCK_PARSE; + return $this->toString( self::FORMAT_BLOCK_PARSE ); + } + + /** + * Returns the message text. {{-transformation is done and the result + * is escaped excluding any raw parameters. + * + * @since 1.17 + * + * @return string Escaped message text. + */ + public function escaped() { + $this->format = self::FORMAT_ESCAPED; + return $this->toString( self::FORMAT_ESCAPED ); + } + + /** + * Check whether a message key has been defined currently. + * + * @since 1.17 + * + * @return bool + */ + public function exists() { + return $this->fetchMessage() !== false; + } + + /** + * Check whether a message does not exist, or is an empty string + * + * @since 1.18 + * @todo FIXME: Merge with isDisabled()? + * + * @return bool + */ + public function isBlank() { + $message = $this->fetchMessage(); + return $message === false || $message === ''; + } + + /** + * Check whether a message does not exist, is an empty string, or is "-". + * + * @since 1.18 + * + * @return bool + */ + public function isDisabled() { + $message = $this->fetchMessage(); + return $message === false || $message === '' || $message === '-'; + } + + /** + * @since 1.17 + * + * @param mixed $raw + * + * @return array Array with a single "raw" key. + */ + public static function rawParam( $raw ) { + return [ 'raw' => $raw ]; + } + + /** + * @since 1.18 + * + * @param mixed $num + * + * @return array Array with a single "num" key. + */ + public static function numParam( $num ) { + return [ 'num' => $num ]; + } + + /** + * @since 1.22 + * + * @param int $duration + * + * @return int[] Array with a single "duration" key. + */ + public static function durationParam( $duration ) { + return [ 'duration' => $duration ]; + } + + /** + * @since 1.22 + * + * @param string $expiry + * + * @return string[] Array with a single "expiry" key. + */ + public static function expiryParam( $expiry ) { + return [ 'expiry' => $expiry ]; + } + + /** + * @since 1.22 + * + * @param int $period + * + * @return int[] Array with a single "period" key. + */ + public static function timeperiodParam( $period ) { + return [ 'period' => $period ]; + } + + /** + * @since 1.22 + * + * @param int $size + * + * @return int[] Array with a single "size" key. + */ + public static function sizeParam( $size ) { + return [ 'size' => $size ]; + } + + /** + * @since 1.22 + * + * @param int $bitrate + * + * @return int[] Array with a single "bitrate" key. + */ + public static function bitrateParam( $bitrate ) { + return [ 'bitrate' => $bitrate ]; + } + + /** + * @since 1.25 + * + * @param string $plaintext + * + * @return string[] Array with a single "plaintext" key. + */ + public static function plaintextParam( $plaintext ) { + return [ 'plaintext' => $plaintext ]; + } + + /** + * @since 1.29 + * + * @param array $list + * @param string $type 'comma', 'semicolon', 'pipe', 'text' + * @return array Array with "list" and "type" keys. + */ + public static function listParam( array $list, $type = 'text' ) { + if ( !isset( self::$listTypeMap[$type] ) ) { + throw new InvalidArgumentException( + "Invalid type '$type'. Known types are: " . implode( ', ', array_keys( self::$listTypeMap ) ) + ); + } + return [ 'list' => $list, 'type' => $type ]; + } + + /** + * Substitutes any parameters into the message text. + * + * @since 1.17 + * + * @param string $message The message text. + * @param string $type Either "before" or "after". + * @param string $format One of the FORMAT_* constants. + * + * @return string + */ + protected function replaceParameters( $message, $type, $format ) { + // A temporary marker for $1 parameters that is only valid + // in non-attribute contexts. However if the entire message is escaped + // then we don't want to use it because it will be mangled in all contexts + // and its unnessary as ->escaped() messages aren't html. + $marker = $format === self::FORMAT_ESCAPED ? '$' : '$\'"'; + $replacementKeys = []; + foreach ( $this->parameters as $n => $param ) { + list( $paramType, $value ) = $this->extractParam( $param, $format ); + if ( $type === 'before' ) { + if ( $paramType === 'before' ) { + $replacementKeys['$' . ( $n + 1 )] = $value; + } else /* $paramType === 'after' */ { + // To protect against XSS from replacing parameters + // inside html attributes, we convert $1 to $'"1. + // In the event that one of the parameters ends up + // in an attribute, either the ' or the " will be + // escaped, breaking the replacement and avoiding XSS. + $replacementKeys['$' . ( $n + 1 )] = $marker . ( $n + 1 ); + } + } elseif ( $paramType === 'after' ) { + $replacementKeys[$marker . ( $n + 1 )] = $value; + } + } + return strtr( $message, $replacementKeys ); + } + + /** + * Extracts the parameter type and preprocessed the value if needed. + * + * @since 1.18 + * + * @param mixed $param Parameter as defined in this class. + * @param string $format One of the FORMAT_* constants. + * + * @return array Array with the parameter type (either "before" or "after") and the value. + */ + protected function extractParam( $param, $format ) { + if ( is_array( $param ) ) { + if ( isset( $param['raw'] ) ) { + return [ 'after', $param['raw'] ]; + } elseif ( isset( $param['num'] ) ) { + // Replace number params always in before step for now. + // No support for combined raw and num params + return [ 'before', $this->getLanguage()->formatNum( $param['num'] ) ]; + } elseif ( isset( $param['duration'] ) ) { + return [ 'before', $this->getLanguage()->formatDuration( $param['duration'] ) ]; + } elseif ( isset( $param['expiry'] ) ) { + return [ 'before', $this->getLanguage()->formatExpiry( $param['expiry'] ) ]; + } elseif ( isset( $param['period'] ) ) { + return [ 'before', $this->getLanguage()->formatTimePeriod( $param['period'] ) ]; + } elseif ( isset( $param['size'] ) ) { + return [ 'before', $this->getLanguage()->formatSize( $param['size'] ) ]; + } elseif ( isset( $param['bitrate'] ) ) { + return [ 'before', $this->getLanguage()->formatBitrate( $param['bitrate'] ) ]; + } elseif ( isset( $param['plaintext'] ) ) { + return [ 'after', $this->formatPlaintext( $param['plaintext'], $format ) ]; + } elseif ( isset( $param['list'] ) ) { + return $this->formatListParam( $param['list'], $param['type'], $format ); + } else { + if ( !is_scalar( $param ) ) { + $param = serialize( $param ); + } + LoggerFactory::getInstance( 'Bug58676' )->warning( + 'Invalid parameter for message "{msgkey}": {param}', + [ + 'exception' => new Exception, + 'msgkey' => $this->getKey(), + 'param' => htmlspecialchars( $param ), + ] + ); + + return [ 'before', '[INVALID]' ]; + } + } elseif ( $param instanceof Message ) { + // Match language, flags, etc. to the current message. + $msg = clone $param; + if ( $msg->language !== $this->language || $msg->useDatabase !== $this->useDatabase ) { + // Cache depends on these parameters + $msg->message = null; + } + $msg->interface = $this->interface; + $msg->language = $this->language; + $msg->useDatabase = $this->useDatabase; + $msg->title = $this->title; + + // DWIM + if ( $format === 'block-parse' ) { + $format = 'parse'; + } + $msg->format = $format; + + // Message objects should not be before parameters because + // then they'll get double escaped. If the message needs to be + // escaped, it'll happen right here when we call toString(). + return [ 'after', $msg->toString( $format ) ]; + } else { + return [ 'before', $param ]; + } + } + + /** + * Wrapper for what ever method we use to parse wikitext. + * + * @since 1.17 + * + * @param string $string Wikitext message contents. + * + * @return string Wikitext parsed into HTML. + */ + protected function parseText( $string ) { + $out = MessageCache::singleton()->parse( + $string, + $this->title, + /*linestart*/true, + $this->interface, + $this->getLanguage() + ); + + return $out instanceof ParserOutput + ? $out->getText( [ + 'enableSectionEditLinks' => false, + // Wrapping messages in an extra
is probably not expected. If + // they're outside the content area they probably shouldn't be + // targeted by CSS that's targeting the parser output, and if + // they're inside they already are from the outer div. + 'unwrap' => true, + ] ) + : $out; + } + + /** + * Wrapper for what ever method we use to {{-transform wikitext. + * + * @since 1.17 + * + * @param string $string Wikitext message contents. + * + * @return string Wikitext with {{-constructs replaced with their values. + */ + protected function transformText( $string ) { + return MessageCache::singleton()->transform( + $string, + $this->interface, + $this->getLanguage(), + $this->title + ); + } + + /** + * Wrapper for what ever method we use to get message contents. + * + * @since 1.17 + * + * @return string + * @throws MWException If message key array is empty. + */ + protected function fetchMessage() { + if ( $this->message === null ) { + $cache = MessageCache::singleton(); + + foreach ( $this->keysToTry as $key ) { + $message = $cache->get( $key, $this->useDatabase, $this->getLanguage() ); + if ( $message !== false && $message !== '' ) { + break; + } + } + + // NOTE: The constructor makes sure keysToTry isn't empty, + // so we know that $key and $message are initialized. + $this->key = $key; + $this->message = $message; + } + return $this->message; + } + + /** + * Formats a message parameter wrapped with 'plaintext'. Ensures that + * the entire string is displayed unchanged when displayed in the output + * format. + * + * @since 1.25 + * + * @param string $plaintext String to ensure plaintext output of + * @param string $format One of the FORMAT_* constants. + * + * @return string Input plaintext encoded for output to $format + */ + protected function formatPlaintext( $plaintext, $format ) { + switch ( $format ) { + case self::FORMAT_TEXT: + case self::FORMAT_PLAIN: + return $plaintext; + + case self::FORMAT_PARSE: + case self::FORMAT_BLOCK_PARSE: + case self::FORMAT_ESCAPED: + default: + return htmlspecialchars( $plaintext, ENT_QUOTES ); + } + } + + /** + * Formats a list of parameters as a concatenated string. + * @since 1.29 + * @param array $params + * @param string $listType + * @param string $format One of the FORMAT_* constants. + * @return array Array with the parameter type (either "before" or "after") and the value. + */ + protected function formatListParam( array $params, $listType, $format ) { + if ( !isset( self::$listTypeMap[$listType] ) ) { + $warning = 'Invalid list type for message "' . $this->getKey() . '": ' + . htmlspecialchars( $listType ) + . ' (params are ' . htmlspecialchars( serialize( $params ) ) . ')'; + trigger_error( $warning, E_USER_WARNING ); + $e = new Exception; + wfDebugLog( 'Bug58676', $warning . "\n" . $e->getTraceAsString() ); + return [ 'before', '[INVALID]' ]; + } + $func = self::$listTypeMap[$listType]; + + // Handle an empty list sensibly + if ( !$params ) { + return [ 'before', $this->getLanguage()->$func( [] ) ]; + } + + // First, determine what kinds of list items we have + $types = []; + $vars = []; + $list = []; + foreach ( $params as $n => $p ) { + list( $type, $value ) = $this->extractParam( $p, $format ); + $types[$type] = true; + $list[] = $value; + $vars[] = '$' . ( $n + 1 ); + } + + // Easy case: all are 'before' or 'after', so just join the + // values and use the same type. + if ( count( $types ) === 1 ) { + return [ key( $types ), $this->getLanguage()->$func( $list ) ]; + } + + // Hard case: We need to process each value per its type, then + // return the concatenated values as 'after'. We handle this by turning + // the list into a RawMessage and processing that as a parameter. + $vars = $this->getLanguage()->$func( $vars ); + return $this->extractParam( new RawMessage( $vars, $params ), $format ); + } +} diff --git a/includes/language/MessageLocalizer.php b/includes/language/MessageLocalizer.php new file mode 100644 index 0000000000..9a1796b140 --- /dev/null +++ b/includes/language/MessageLocalizer.php @@ -0,0 +1,43 @@ +has( $key, $maxAge ) ) { - return null; + return $default; } $this->ping( $key ); @@ -201,10 +206,11 @@ class MapCacheLRU implements IExpiringStore, Serializable { /** * @param string|int $key * @param string|int $field - * @param float $maxAge Ignore items older than this many seconds [optional] (since 1.32) + * @param float $maxAge Ignore items older than this many seconds [default: INF] * @return bool + * @since 1.32 Added $maxAge */ - public function hasField( $key, $field, $maxAge = 0.0 ) { + public function hasField( $key, $field, $maxAge = INF ) { $value = $this->get( $key ); if ( !is_int( $field ) && !is_string( $field ) ) { @@ -222,10 +228,11 @@ class MapCacheLRU implements IExpiringStore, Serializable { /** * @param string|int $key * @param string|int $field - * @param float $maxAge Ignore items older than this many seconds [optional] (since 1.32) + * @param float $maxAge Ignore items older than this many seconds [default: INF] * @return mixed Returns null if the key was not found or is older than $maxAge + * @since 1.32 Added $maxAge */ - public function getField( $key, $field, $maxAge = 0.0 ) { + public function getField( $key, $field, $maxAge = INF ) { if ( !$this->hasField( $key, $field, $maxAge ) ) { return null; } @@ -249,12 +256,13 @@ class MapCacheLRU implements IExpiringStore, Serializable { * @since 1.28 * @param string $key * @param callable $callback Callback that will produce the value - * @param float $rank Bottom fraction of the list where keys start off [Default: 1.0] - * @param float $maxAge Ignore items older than this many seconds [Default: 0.0] (since 1.32) + * @param float $rank Bottom fraction of the list where keys start off [default: 1.0] + * @param float $maxAge Ignore items older than this many seconds [default: INF] * @return mixed The cached value if found or the result of $callback otherwise + * @since 1.32 Added $maxAge */ public function getWithSetCallback( - $key, callable $callback, $rank = self::RANK_TOP, $maxAge = 0.0 + $key, callable $callback, $rank = self::RANK_TOP, $maxAge = INF ) { if ( $this->has( $key, $maxAge ) ) { $value = $this->get( $key ); diff --git a/includes/libs/MultiHttpClient.php b/includes/libs/MultiHttpClient.php deleted file mode 100644 index a6135aeb7a..0000000000 --- a/includes/libs/MultiHttpClient.php +++ /dev/null @@ -1,609 +0,0 @@ - (uses RFC 3986) - * - headers :
- * - body : source to get the HTTP request body from; - * this can simply be a string (always), a resource for - * PUT requests, and a field/value array for POST request; - * array bodies are encoded as multipart/form-data and strings - * use application/x-www-form-urlencoded (headers sent automatically) - * - stream : resource to stream the HTTP response body to - * - proxy : HTTP proxy to use - * - flags : map of boolean flags which supports: - * - relayResponseHeaders : write out header via header() - * Request maps can use integer index 0 instead of 'method' and 1 instead of 'url'. - * - * @since 1.23 - */ -class MultiHttpClient implements LoggerAwareInterface { - /** @var resource */ - protected $multiHandle = null; // curl_multi handle - /** @var string|null SSL certificates path */ - protected $caBundlePath; - /** @var float */ - protected $connTimeout = 10; - /** @var float */ - protected $reqTimeout = 300; - /** @var bool */ - protected $usePipelining = false; - /** @var int */ - protected $maxConnsPerHost = 50; - /** @var string|null proxy */ - protected $proxy; - /** @var string */ - protected $userAgent = 'wikimedia/multi-http-client v1.0'; - /** @var LoggerInterface */ - protected $logger; - - // In PHP 7 due to https://bugs.php.net/bug.php?id=76480 the request/connect - // timeouts are periodically polled instead of being accurately respected. - // The select timeout is set to the minimum timeout multiplied by this factor. - const TIMEOUT_ACCURACY_FACTOR = 0.1; - - /** - * @param array $options - * - connTimeout : default connection timeout (seconds) - * - reqTimeout : default request timeout (seconds) - * - proxy : HTTP proxy to use - * - usePipelining : whether to use HTTP pipelining if possible (for all hosts) - * - maxConnsPerHost : maximum number of concurrent connections (per host) - * - userAgent : The User-Agent header value to send - * - logger : a \Psr\Log\LoggerInterface instance for debug logging - * - caBundlePath : path to specific Certificate Authority bundle (if any) - * @throws Exception - */ - public function __construct( array $options ) { - if ( isset( $options['caBundlePath'] ) ) { - $this->caBundlePath = $options['caBundlePath']; - if ( !file_exists( $this->caBundlePath ) ) { - throw new Exception( "Cannot find CA bundle: " . $this->caBundlePath ); - } - } - static $opts = [ - 'connTimeout', 'reqTimeout', 'usePipelining', 'maxConnsPerHost', - 'proxy', 'userAgent', 'logger' - ]; - foreach ( $opts as $key ) { - if ( isset( $options[$key] ) ) { - $this->$key = $options[$key]; - } - } - if ( $this->logger === null ) { - $this->logger = new NullLogger; - } - } - - /** - * Execute an HTTP(S) request - * - * This method returns a response map of: - * - code : HTTP response code or 0 if there was a serious error - * - reason : HTTP response reason (empty if there was a serious error) - * - headers :
- * - body : HTTP response body or resource (if "stream" was set) - * - error : Any error string - * The map also stores integer-indexed copies of these values. This lets callers do: - * @code - * list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $http->run( $req ); - * @endcode - * @param array $req HTTP request array - * @param array $opts - * - connTimeout : connection timeout per request (seconds) - * - reqTimeout : post-connection timeout per request (seconds) - * @return array Response array for request - */ - public function run( array $req, array $opts = [] ) { - return $this->runMulti( [ $req ], $opts )[0]['response']; - } - - /** - * Execute a set of HTTP(S) requests. - * - * If curl is available, requests will be made concurrently. - * Otherwise, they will be made serially. - * - * The maps are returned by this method with the 'response' field set to a map of: - * - code : HTTP response code or 0 if there was a serious error - * - reason : HTTP response reason (empty if there was a serious error) - * - headers :
- * - body : HTTP response body or resource (if "stream" was set) - * - error : Any error string - * The map also stores integer-indexed copies of these values. This lets callers do: - * @code - * list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $req['response']; - * @endcode - * All headers in the 'headers' field are normalized to use lower case names. - * This is true for the request headers and the response headers. Integer-indexed - * method/URL entries will also be changed to use the corresponding string keys. - * - * @param array $reqs Map of HTTP request arrays - * @param array $opts - * - connTimeout : connection timeout per request (seconds) - * - reqTimeout : post-connection timeout per request (seconds) - * - usePipelining : whether to use HTTP pipelining if possible - * - maxConnsPerHost : maximum number of concurrent connections (per host) - * @return array $reqs With response array populated for each - * @throws Exception - */ - public function runMulti( array $reqs, array $opts = [] ) { - $this->normalizeRequests( $reqs ); - if ( $this->isCurlEnabled() ) { - return $this->runMultiCurl( $reqs, $opts ); - } else { - return $this->runMultiHttp( $reqs, $opts ); - } - } - - /** - * Determines if the curl extension is available - * - * @return bool true if curl is available, false otherwise. - */ - protected function isCurlEnabled() { - return extension_loaded( 'curl' ); - } - - /** - * Execute a set of HTTP(S) requests concurrently - * - * @see MultiHttpClient::runMulti() - * - * @param array $reqs Map of HTTP request arrays - * @param array $opts - * - connTimeout : connection timeout per request (seconds) - * - reqTimeout : post-connection timeout per request (seconds) - * - usePipelining : whether to use HTTP pipelining if possible - * - maxConnsPerHost : maximum number of concurrent connections (per host) - * @return array $reqs With response array populated for each - * @throws Exception - */ - private function runMultiCurl( array $reqs, array $opts = [] ) { - $chm = $this->getCurlMulti(); - - $selectTimeout = $this->getSelectTimeout( $opts ); - - // Add all of the required cURL handles... - $handles = []; - foreach ( $reqs as $index => &$req ) { - $handles[$index] = $this->getCurlHandle( $req, $opts ); - if ( count( $reqs ) > 1 ) { - // https://github.com/guzzle/guzzle/issues/349 - curl_setopt( $handles[$index], CURLOPT_FORBID_REUSE, true ); - } - } - unset( $req ); // don't assign over this by accident - - $indexes = array_keys( $reqs ); - if ( isset( $opts['usePipelining'] ) ) { - curl_multi_setopt( $chm, CURLMOPT_PIPELINING, (int)$opts['usePipelining'] ); - } - if ( isset( $opts['maxConnsPerHost'] ) ) { - // Keep these sockets around as they may be needed later in the request - curl_multi_setopt( $chm, CURLMOPT_MAXCONNECTS, (int)$opts['maxConnsPerHost'] ); - } - - // @TODO: use a per-host rolling handle window (e.g. CURLMOPT_MAX_HOST_CONNECTIONS) - $batches = array_chunk( $indexes, $this->maxConnsPerHost ); - $infos = []; - - foreach ( $batches as $batch ) { - // Attach all cURL handles for this batch - foreach ( $batch as $index ) { - curl_multi_add_handle( $chm, $handles[$index] ); - } - // Execute the cURL handles concurrently... - $active = null; // handles still being processed - do { - // Do any available work... - do { - $mrc = curl_multi_exec( $chm, $active ); - $info = curl_multi_info_read( $chm ); - if ( $info !== false ) { - $infos[(int)$info['handle']] = $info; - } - } while ( $mrc == CURLM_CALL_MULTI_PERFORM ); - // Wait (if possible) for available work... - if ( $active > 0 && $mrc == CURLM_OK && curl_multi_select( $chm, $selectTimeout ) == -1 ) { - // PHP bug 63411; https://curl.haxx.se/libcurl/c/curl_multi_fdset.html - usleep( 5000 ); // 5ms - } - } while ( $active > 0 && $mrc == CURLM_OK ); - } - - // Remove all of the added cURL handles and check for errors... - foreach ( $reqs as $index => &$req ) { - $ch = $handles[$index]; - curl_multi_remove_handle( $chm, $ch ); - - if ( isset( $infos[(int)$ch] ) ) { - $info = $infos[(int)$ch]; - $errno = $info['result']; - if ( $errno !== 0 ) { - $req['response']['error'] = "(curl error: $errno)"; - if ( function_exists( 'curl_strerror' ) ) { - $req['response']['error'] .= " " . curl_strerror( $errno ); - } - $this->logger->warning( "Error fetching URL \"{$req['url']}\": " . - $req['response']['error'] ); - } - } else { - $req['response']['error'] = "(curl error: no status set)"; - } - - // For convenience with the list() operator - $req['response'][0] = $req['response']['code']; - $req['response'][1] = $req['response']['reason']; - $req['response'][2] = $req['response']['headers']; - $req['response'][3] = $req['response']['body']; - $req['response'][4] = $req['response']['error']; - curl_close( $ch ); - // Close any string wrapper file handles - if ( isset( $req['_closeHandle'] ) ) { - fclose( $req['_closeHandle'] ); - unset( $req['_closeHandle'] ); - } - } - unset( $req ); // don't assign over this by accident - - // Restore the default settings - curl_multi_setopt( $chm, CURLMOPT_PIPELINING, (int)$this->usePipelining ); - curl_multi_setopt( $chm, CURLMOPT_MAXCONNECTS, (int)$this->maxConnsPerHost ); - - return $reqs; - } - - /** - * @param array &$req HTTP request map - * @param array $opts - * - connTimeout : default connection timeout - * - reqTimeout : default request timeout - * @return resource - * @throws Exception - */ - protected function getCurlHandle( array &$req, array $opts = [] ) { - $ch = curl_init(); - - curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT_MS, - ( $opts['connTimeout'] ?? $this->connTimeout ) * 1000 ); - curl_setopt( $ch, CURLOPT_PROXY, $req['proxy'] ?? $this->proxy ); - curl_setopt( $ch, CURLOPT_TIMEOUT_MS, - ( $opts['reqTimeout'] ?? $this->reqTimeout ) * 1000 ); - curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, 1 ); - curl_setopt( $ch, CURLOPT_MAXREDIRS, 4 ); - curl_setopt( $ch, CURLOPT_HEADER, 0 ); - if ( !is_null( $this->caBundlePath ) ) { - curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, true ); - curl_setopt( $ch, CURLOPT_CAINFO, $this->caBundlePath ); - } - curl_setopt( $ch, CURLOPT_RETURNTRANSFER, 1 ); - - $url = $req['url']; - $query = http_build_query( $req['query'], '', '&', PHP_QUERY_RFC3986 ); - if ( $query != '' ) { - $url .= strpos( $req['url'], '?' ) === false ? "?$query" : "&$query"; - } - curl_setopt( $ch, CURLOPT_URL, $url ); - - curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, $req['method'] ); - if ( $req['method'] === 'HEAD' ) { - curl_setopt( $ch, CURLOPT_NOBODY, 1 ); - } - - if ( $req['method'] === 'PUT' ) { - curl_setopt( $ch, CURLOPT_PUT, 1 ); - if ( is_resource( $req['body'] ) ) { - curl_setopt( $ch, CURLOPT_INFILE, $req['body'] ); - if ( isset( $req['headers']['content-length'] ) ) { - curl_setopt( $ch, CURLOPT_INFILESIZE, $req['headers']['content-length'] ); - } elseif ( isset( $req['headers']['transfer-encoding'] ) && - $req['headers']['transfer-encoding'] === 'chunks' - ) { - curl_setopt( $ch, CURLOPT_UPLOAD, true ); - } else { - throw new Exception( "Missing 'Content-Length' or 'Transfer-Encoding' header." ); - } - } elseif ( $req['body'] !== '' ) { - $fp = fopen( "php://temp", "wb+" ); - fwrite( $fp, $req['body'], strlen( $req['body'] ) ); - rewind( $fp ); - curl_setopt( $ch, CURLOPT_INFILE, $fp ); - curl_setopt( $ch, CURLOPT_INFILESIZE, strlen( $req['body'] ) ); - $req['_closeHandle'] = $fp; // remember to close this later - } else { - curl_setopt( $ch, CURLOPT_INFILESIZE, 0 ); - } - curl_setopt( $ch, CURLOPT_READFUNCTION, - function ( $ch, $fd, $length ) { - $data = fread( $fd, $length ); - $len = strlen( $data ); - return $data; - } - ); - } elseif ( $req['method'] === 'POST' ) { - curl_setopt( $ch, CURLOPT_POST, 1 ); - // Don't interpret POST parameters starting with '@' as file uploads, because this - // makes it impossible to POST plain values starting with '@' (and causes security - // issues potentially exposing the contents of local files). - curl_setopt( $ch, CURLOPT_SAFE_UPLOAD, true ); - curl_setopt( $ch, CURLOPT_POSTFIELDS, $req['body'] ); - } else { - if ( is_resource( $req['body'] ) || $req['body'] !== '' ) { - throw new Exception( "HTTP body specified for a non PUT/POST request." ); - } - $req['headers']['content-length'] = 0; - } - - if ( !isset( $req['headers']['user-agent'] ) ) { - $req['headers']['user-agent'] = $this->userAgent; - } - - $headers = []; - foreach ( $req['headers'] as $name => $value ) { - if ( strpos( $name, ': ' ) ) { - throw new Exception( "Headers cannot have ':' in the name." ); - } - $headers[] = $name . ': ' . trim( $value ); - } - curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers ); - - curl_setopt( $ch, CURLOPT_HEADERFUNCTION, - function ( $ch, $header ) use ( &$req ) { - if ( !empty( $req['flags']['relayResponseHeaders'] ) && trim( $header ) !== '' ) { - header( $header ); - } - $length = strlen( $header ); - $matches = []; - if ( preg_match( "/^(HTTP\/1\.[01]) (\d{3}) (.*)/", $header, $matches ) ) { - $req['response']['code'] = (int)$matches[2]; - $req['response']['reason'] = trim( $matches[3] ); - return $length; - } - if ( strpos( $header, ":" ) === false ) { - return $length; - } - list( $name, $value ) = explode( ":", $header, 2 ); - $name = strtolower( $name ); - $value = trim( $value ); - if ( isset( $req['response']['headers'][$name] ) ) { - $req['response']['headers'][$name] .= ', ' . $value; - } else { - $req['response']['headers'][$name] = $value; - } - return $length; - } - ); - - if ( isset( $req['stream'] ) ) { - // Don't just use CURLOPT_FILE as that might give: - // curl_setopt(): cannot represent a stream of type Output as a STDIO FILE* - // The callback here handles both normal files and php://temp handles. - curl_setopt( $ch, CURLOPT_WRITEFUNCTION, - function ( $ch, $data ) use ( &$req ) { - return fwrite( $req['stream'], $data ); - } - ); - } else { - curl_setopt( $ch, CURLOPT_WRITEFUNCTION, - function ( $ch, $data ) use ( &$req ) { - $req['response']['body'] .= $data; - return strlen( $data ); - } - ); - } - - return $ch; - } - - /** - * @return resource - * @throws Exception - */ - protected function getCurlMulti() { - if ( !$this->multiHandle ) { - if ( !function_exists( 'curl_multi_init' ) ) { - throw new Exception( "PHP cURL function curl_multi_init missing. " . - "Check https://www.mediawiki.org/wiki/Manual:CURL" ); - } - $cmh = curl_multi_init(); - curl_multi_setopt( $cmh, CURLMOPT_PIPELINING, (int)$this->usePipelining ); - curl_multi_setopt( $cmh, CURLMOPT_MAXCONNECTS, (int)$this->maxConnsPerHost ); - $this->multiHandle = $cmh; - } - return $this->multiHandle; - } - - /** - * Execute a set of HTTP(S) requests sequentially. - * - * @see MultiHttpClient::runMulti() - * @todo Remove dependency on MediaWikiServices: use a separate HTTP client - * library or copy code from PhpHttpRequest - * @param array $reqs Map of HTTP request arrays - * @param array $opts - * - connTimeout : connection timeout per request (seconds) - * - reqTimeout : post-connection timeout per request (seconds) - * @return array $reqs With response array populated for each - * @throws Exception - */ - private function runMultiHttp( array $reqs, array $opts = [] ) { - $httpOptions = [ - 'timeout' => $opts['reqTimeout'] ?? $this->reqTimeout, - 'connectTimeout' => $opts['connTimeout'] ?? $this->connTimeout, - 'logger' => $this->logger, - 'caInfo' => $this->caBundlePath, - ]; - foreach ( $reqs as &$req ) { - $reqOptions = $httpOptions + [ - 'method' => $req['method'], - 'proxy' => $req['proxy'] ?? $this->proxy, - 'userAgent' => $req['headers']['user-agent'] ?? $this->userAgent, - 'postData' => $req['body'], - ]; - - $url = $req['url']; - $query = http_build_query( $req['query'], '', '&', PHP_QUERY_RFC3986 ); - if ( $query != '' ) { - $url .= strpos( $req['url'], '?' ) === false ? "?$query" : "&$query"; - } - - $httpRequest = MediaWikiServices::getInstance()->getHttpRequestFactory()->create( - $url, $reqOptions ); - $sv = $httpRequest->execute()->getStatusValue(); - - $respHeaders = array_map( - function ( $v ) { - return implode( ', ', $v ); - }, - $httpRequest->getResponseHeaders() ); - - $req['response'] = [ - 'code' => $httpRequest->getStatus(), - 'reason' => '', - 'headers' => $respHeaders, - 'body' => $httpRequest->getContent(), - 'error' => '', - ]; - - if ( !$sv->isOk() ) { - $svErrors = $sv->getErrors(); - if ( isset( $svErrors[0] ) ) { - $req['response']['error'] = $svErrors[0]['message']; - - // param values vary per failure type (ex. unknown host vs unknown page) - if ( isset( $svErrors[0]['params'][0] ) ) { - if ( is_numeric( $svErrors[0]['params'][0] ) ) { - if ( isset( $svErrors[0]['params'][1] ) ) { - $req['response']['reason'] = $svErrors[0]['params'][1]; - } - } else { - $req['response']['reason'] = $svErrors[0]['params'][0]; - } - } - } - } - - $req['response'][0] = $req['response']['code']; - $req['response'][1] = $req['response']['reason']; - $req['response'][2] = $req['response']['headers']; - $req['response'][3] = $req['response']['body']; - $req['response'][4] = $req['response']['error']; - } - - return $reqs; - } - - /** - * Normalize request information - * - * @param array $reqs the requests to normalize - */ - private function normalizeRequests( array &$reqs ) { - foreach ( $reqs as &$req ) { - $req['response'] = [ - 'code' => 0, - 'reason' => '', - 'headers' => [], - 'body' => '', - 'error' => '' - ]; - if ( isset( $req[0] ) ) { - $req['method'] = $req[0]; // short-form - unset( $req[0] ); - } - if ( isset( $req[1] ) ) { - $req['url'] = $req[1]; // short-form - unset( $req[1] ); - } - if ( !isset( $req['method'] ) ) { - throw new Exception( "Request has no 'method' field set." ); - } elseif ( !isset( $req['url'] ) ) { - throw new Exception( "Request has no 'url' field set." ); - } - $this->logger->debug( "{$req['method']}: {$req['url']}" ); - $req['query'] = $req['query'] ?? []; - $headers = []; // normalized headers - if ( isset( $req['headers'] ) ) { - foreach ( $req['headers'] as $name => $value ) { - $headers[strtolower( $name )] = $value; - } - } - $req['headers'] = $headers; - if ( !isset( $req['body'] ) ) { - $req['body'] = ''; - $req['headers']['content-length'] = 0; - } - $req['flags'] = $req['flags'] ?? []; - } - } - - /** - * Get a suitable select timeout for the given options. - * - * @param array $opts - * @return float - */ - private function getSelectTimeout( $opts ) { - $connTimeout = $opts['connTimeout'] ?? $this->connTimeout; - $reqTimeout = $opts['reqTimeout'] ?? $this->reqTimeout; - $timeouts = array_filter( [ $connTimeout, $reqTimeout ] ); - if ( count( $timeouts ) === 0 ) { - return 1; - } - - $selectTimeout = min( $timeouts ) * self::TIMEOUT_ACCURACY_FACTOR; - // Minimum 10us for sanity - if ( $selectTimeout < 10e-6 ) { - $selectTimeout = 10e-6; - } - return $selectTimeout; - } - - /** - * Register a logger - * - * @param LoggerInterface $logger - */ - public function setLogger( LoggerInterface $logger ) { - $this->logger = $logger; - } - - function __destruct() { - if ( $this->multiHandle ) { - curl_multi_close( $this->multiHandle ); - } - } -} diff --git a/includes/libs/ParamValidator/Callbacks.php b/includes/libs/ParamValidator/Callbacks.php new file mode 100644 index 0000000000..d94a81fbd5 --- /dev/null +++ b/includes/libs/ParamValidator/Callbacks.php @@ -0,0 +1,78 @@ + [ 'class' => TypeDef\BooleanDef::class ], + 'checkbox' => [ 'class' => TypeDef\PresenceBooleanDef::class ], + 'integer' => [ 'class' => TypeDef\IntegerDef::class ], + 'limit' => [ 'class' => TypeDef\LimitDef::class ], + 'float' => [ 'class' => TypeDef\FloatDef::class ], + 'double' => [ 'class' => TypeDef\FloatDef::class ], + 'string' => [ 'class' => TypeDef\StringDef::class ], + 'password' => [ 'class' => TypeDef\PasswordDef::class ], + 'NULL' => [ + 'class' => TypeDef\StringDef::class, + 'args' => [ [ + 'allowEmptyWhenRequired' => true, + ] ], + ], + 'timestamp' => [ 'class' => TypeDef\TimestampDef::class ], + 'upload' => [ 'class' => TypeDef\UploadDef::class ], + 'enum' => [ 'class' => TypeDef\EnumDef::class ], + ]; + + /** @var Callbacks */ + private $callbacks; + + /** @var ObjectFactory */ + private $objectFactory; + + /** @var (TypeDef|array)[] Map parameter type names to TypeDef objects or ObjectFactory specs */ + private $typeDefs = []; + + /** @var int Default values for PARAM_ISMULTI_LIMIT1 */ + private $ismultiLimit1; + + /** @var int Default values for PARAM_ISMULTI_LIMIT2 */ + private $ismultiLimit2; + + /** + * @param Callbacks $callbacks + * @param ObjectFactory $objectFactory To turn specs into TypeDef objects + * @param array $options Associative array of additional settings + * - 'typeDefs': (array) As for addTypeDefs(). If omitted, self::$STANDARD_TYPES will be used. + * Pass an empty array if you want to start with no registered types. + * - 'ismultiLimits': (int[]) Two ints, being the default values for PARAM_ISMULTI_LIMIT1 and + * PARAM_ISMULTI_LIMIT2. If not given, defaults to `[ 50, 500 ]`. + */ + public function __construct( + Callbacks $callbacks, + ObjectFactory $objectFactory, + array $options = [] + ) { + $this->callbacks = $callbacks; + $this->objectFactory = $objectFactory; + + $this->addTypeDefs( $options['typeDefs'] ?? self::$STANDARD_TYPES ); + $this->ismultiLimit1 = $options['ismultiLimits'][0] ?? 50; + $this->ismultiLimit2 = $options['ismultiLimits'][1] ?? 500; + } + + /** + * List known type names + * @return string[] + */ + public function knownTypes() { + return array_keys( $this->typeDefs ); + } + + /** + * Register multiple type handlers + * + * @see addTypeDef() + * @param array $typeDefs Associative array mapping `$name` to `$typeDef`. + */ + public function addTypeDefs( array $typeDefs ) { + foreach ( $typeDefs as $name => $def ) { + $this->addTypeDef( $name, $def ); + } + } + + /** + * Register a type handler + * + * To allow code to omit PARAM_TYPE in settings arrays to derive the type + * from PARAM_DEFAULT, it is strongly recommended that the following types be + * registered: "boolean", "integer", "double", "string", "NULL", and "enum". + * + * When using ObjectFactory specs, the following extra arguments are passed: + * - The Callbacks object for this ParamValidator instance. + * + * @param string $name Type name + * @param TypeDef|array $typeDef Type handler or ObjectFactory spec to create one. + */ + public function addTypeDef( $name, $typeDef ) { + Assert::parameterType( + implode( '|', [ TypeDef::class, 'array' ] ), + $typeDef, + '$typeDef' + ); + + if ( isset( $this->typeDefs[$name] ) ) { + throw new InvalidArgumentException( "Type '$name' is already registered" ); + } + $this->typeDefs[$name] = $typeDef; + } + + /** + * Register a type handler, overriding any existing handler + * @see addTypeDef + * @param string $name Type name + * @param TypeDef|array|null $typeDef As for addTypeDef, or null to unregister a type. + */ + public function overrideTypeDef( $name, $typeDef ) { + Assert::parameterType( + implode( '|', [ TypeDef::class, 'array', 'null' ] ), + $typeDef, + '$typeDef' + ); + + if ( $typeDef === null ) { + unset( $this->typeDefs[$name] ); + } else { + $this->typeDefs[$name] = $typeDef; + } + } + + /** + * Test if a type is registered + * @param string $name Type name + * @return bool + */ + public function hasTypeDef( $name ) { + return isset( $this->typeDefs[$name] ); + } + + /** + * Get the TypeDef for a type + * @param string|array $type Any array is considered equivalent to the string "enum". + * @return TypeDef|null + */ + public function getTypeDef( $type ) { + if ( is_array( $type ) ) { + $type = 'enum'; + } + + if ( !isset( $this->typeDefs[$type] ) ) { + return null; + } + + $def = $this->typeDefs[$type]; + if ( !$def instanceof TypeDef ) { + $def = $this->objectFactory->createObject( $def, [ + 'extraArgs' => [ $this->callbacks ], + 'assertClass' => TypeDef::class, + ] ); + $this->typeDefs[$type] = $def; + } + + return $def; + } + + /** + * Normalize a parameter settings array + * @param array|mixed $settings Default value or an array of settings + * using PARAM_* constants. + * @return array + */ + public function normalizeSettings( $settings ) { + // Shorthand + if ( !is_array( $settings ) ) { + $settings = [ + self::PARAM_DEFAULT => $settings, + ]; + } + + // When type is not given, determine it from the type of the PARAM_DEFAULT + if ( !isset( $settings[self::PARAM_TYPE] ) ) { + $settings[self::PARAM_TYPE] = gettype( $settings[self::PARAM_DEFAULT] ?? null ); + } + + $typeDef = $this->getTypeDef( $settings[self::PARAM_TYPE] ); + if ( $typeDef ) { + $settings = $typeDef->normalizeSettings( $settings ); + } + + return $settings; + } + + /** + * Fetch and valiate a parameter value using a settings array + * + * @param string $name Parameter name + * @param array|mixed $settings Default value or an array of settings + * using PARAM_* constants. + * @param array $options Options array, passed through to the TypeDef and Callbacks. + * @return mixed Validated parameter value + * @throws ValidationException if the value is invalid + */ + public function getValue( $name, $settings, array $options = [] ) { + $settings = $this->normalizeSettings( $settings ); + + $typeDef = $this->getTypeDef( $settings[self::PARAM_TYPE] ); + if ( !$typeDef ) { + throw new DomainException( + "Param $name's type is unknown - {$settings[self::PARAM_TYPE]}" + ); + } + + $value = $typeDef->getValue( $name, $settings, $options ); + + if ( $value !== null ) { + if ( !empty( $settings[self::PARAM_SENSITIVE] ) ) { + $this->callbacks->recordCondition( + new ValidationException( $name, $value, $settings, 'param-sensitive', [] ), + $options + ); + } + + // Set a warning if a deprecated parameter has been passed + if ( !empty( $settings[self::PARAM_DEPRECATED] ) ) { + $this->callbacks->recordCondition( + new ValidationException( $name, $value, $settings, 'param-deprecated', [] ), + $options + ); + } + } elseif ( isset( $settings[self::PARAM_DEFAULT] ) ) { + $value = $settings[self::PARAM_DEFAULT]; + } + + return $this->validateValue( $name, $value, $settings, $options ); + } + + /** + * Valiate a parameter value using a settings array + * + * @param string $name Parameter name + * @param null|mixed $value Parameter value + * @param array|mixed $settings Default value or an array of settings + * using PARAM_* constants. + * @param array $options Options array, passed through to the TypeDef and Callbacks. + * - An additional option, 'values-list', will be set when processing the + * values of a multi-valued parameter. + * @return mixed Validated parameter value(s) + * @throws ValidationException if the value is invalid + */ + public function validateValue( $name, $value, $settings, array $options = [] ) { + $settings = $this->normalizeSettings( $settings ); + + $typeDef = $this->getTypeDef( $settings[self::PARAM_TYPE] ); + if ( !$typeDef ) { + throw new DomainException( + "Param $name's type is unknown - {$settings[self::PARAM_TYPE]}" + ); + } + + if ( $value === null ) { + if ( !empty( $settings[self::PARAM_REQUIRED] ) ) { + throw new ValidationException( $name, $value, $settings, 'missingparam', [] ); + } + return null; + } + + // Non-multi + if ( empty( $settings[self::PARAM_ISMULTI] ) ) { + return $typeDef->validate( $name, $value, $settings, $options ); + } + + // Split the multi-value and validate each parameter + $limit1 = $settings[self::PARAM_ISMULTI_LIMIT1] ?? $this->ismultiLimit1; + $limit2 = $settings[self::PARAM_ISMULTI_LIMIT2] ?? $this->ismultiLimit2; + $valuesList = is_array( $value ) ? $value : self::explodeMultiValue( $value, $limit2 + 1 ); + + // Handle PARAM_ALL + $enumValues = $typeDef->getEnumValues( $name, $settings, $options ); + if ( is_array( $enumValues ) && isset( $settings[self::PARAM_ALL] ) && + count( $valuesList ) === 1 + ) { + $allValue = is_string( $settings[self::PARAM_ALL] ) + ? $settings[self::PARAM_ALL] + : self::ALL_DEFAULT_STRING; + if ( $valuesList[0] === $allValue ) { + return $enumValues; + } + } + + // Avoid checking useHighLimits() unless it's actually necessary + $sizeLimit = count( $valuesList ) > $limit1 && $this->callbacks->useHighLimits( $options ) + ? $limit2 + : $limit1; + if ( count( $valuesList ) > $sizeLimit ) { + throw new ValidationException( $name, $valuesList, $settings, 'toomanyvalues', [ + 'limit' => $sizeLimit + ] ); + } + + $options['values-list'] = $valuesList; + $validValues = []; + $invalidValues = []; + foreach ( $valuesList as $v ) { + try { + $validValues[] = $typeDef->validate( $name, $v, $settings, $options ); + } catch ( ValidationException $ex ) { + if ( empty( $settings[self::PARAM_IGNORE_INVALID_VALUES] ) ) { + throw $ex; + } + $invalidValues[] = $v; + } + } + if ( $invalidValues ) { + $this->callbacks->recordCondition( + new ValidationException( $name, $value, $settings, 'unrecognizedvalues', [ + 'values' => $invalidValues, + ] ), + $options + ); + } + + // Throw out duplicates if requested + if ( empty( $settings[self::PARAM_ALLOW_DUPLICATES] ) ) { + $validValues = array_values( array_unique( $validValues ) ); + } + + return $validValues; + } + + /** + * Split a multi-valued parameter string, like explode() + * + * Note that, unlike explode(), this will return an empty array when given + * an empty string. + * + * @param string $value + * @param int $limit + * @return string[] + */ + public static function explodeMultiValue( $value, $limit ) { + if ( $value === '' || $value === "\x1f" ) { + return []; + } + + if ( substr( $value, 0, 1 ) === "\x1f" ) { + $sep = "\x1f"; + $value = substr( $value, 1 ); + } else { + $sep = '|'; + } + + return explode( $sep, $value, $limit ); + } + +} diff --git a/includes/libs/ParamValidator/README.md b/includes/libs/ParamValidator/README.md new file mode 100644 index 0000000000..dd992a408a --- /dev/null +++ b/includes/libs/ParamValidator/README.md @@ -0,0 +1,58 @@ +Wikimedia API Parameter Validator +================================= + +This library implements a system for processing and validating parameters to an +API from data like that in PHP's `$_GET`, `$_POST`, and `$_FILES` arrays, based +on a declarative definition of available parameters. + +Usage +----- + +
+use Wikimedia\ParamValidator\ParamValidator;
+use Wikimedia\ParamValidator\TypeDef\IntegerDef;
+use Wikimedia\ParamValidator\SimpleCallbacks as ParamValidatorCallbacks;
+use Wikimedia\ParamValidator\ValidationException;
+
+$validator = new ParamValidator(
+	new ParamValidatorCallbacks( $_POST + $_GET, $_FILES ),
+	$serviceContainer->getObjectFactory()
+);
+
+try {
+	$intValue = $validator->getValue( 'intParam', [
+			ParamValidator::PARAM_TYPE => 'integer',
+			ParamValidator::PARAM_DEFAULT => 0,
+			IntegerDef::PARAM_MIN => 0,
+			IntegerDef::PARAM_MAX => 5,
+	] );
+} catch ( ValidationException $ex ) {
+	$error = lookupI18nMessage( 'param-validator-error-' . $ex->getFailureCode() );
+	echo "Validation error: $error\n";
+}
+
+ +I18n +---- + +This library is designed to generate output in a manner suited to use with an +i18n system. To that end, errors and such are indicated by means of "codes" +consisting of ASCII lowercase letters, digits, and hyphen (and always beginning +with a letter). + +Additional details about each error, such as the allowed range for an integer +value, are similarly returned by means of associative arrays with keys being +similar "code" strings and values being strings, integers, or arrays of strings +that are intended to be formatted as a list (e.g. joined with commas). The +details for any particular "message" will also always have the same keys in the +same order to facilitate use with i18n systems using positional rather than +named parameters. + +For possible codes and their parameters, see the documentation of the relevant +`PARAM_*` constants and TypeDef classes. + +Running tests +------------- + + composer install --prefer-dist + composer test diff --git a/includes/libs/ParamValidator/SimpleCallbacks.php b/includes/libs/ParamValidator/SimpleCallbacks.php new file mode 100644 index 0000000000..77dab92619 --- /dev/null +++ b/includes/libs/ParamValidator/SimpleCallbacks.php @@ -0,0 +1,79 @@ +params = $params; + $this->files = $files; + } + + public function hasParam( $name, array $options ) { + return isset( $this->params[$name] ); + } + + public function getValue( $name, $default, array $options ) { + return $this->params[$name] ?? $default; + } + + public function hasUpload( $name, array $options ) { + return isset( $this->files[$name] ); + } + + public function getUploadedFile( $name, array $options ) { + $file = $this->files[$name] ?? null; + if ( $file && !$file instanceof UploadedFile ) { + $file = new UploadedFile( $file ); + $this->files[$name] = $file; + } + return $file; + } + + public function recordCondition( ValidationException $condition, array $options ) { + $this->conditions[] = $condition; + } + + /** + * Fetch any recorded conditions + * @return array[] + */ + public function getRecordedConditions() { + return $this->conditions; + } + + /** + * Clear any recorded conditions + */ + public function clearRecordedConditions() { + $this->conditions = []; + } + + public function useHighLimits( array $options ) { + return !empty( $options['useHighLimits'] ); + } + +} diff --git a/includes/libs/ParamValidator/TypeDef.php b/includes/libs/ParamValidator/TypeDef.php new file mode 100644 index 0000000000..0d54addc58 --- /dev/null +++ b/includes/libs/ParamValidator/TypeDef.php @@ -0,0 +1,148 @@ +callbacks = $callbacks; + } + + /** + * Get the value from the request + * + * @note Only override this if you need to use something other than + * $this->callbacks->getValue() to fetch the value. Reformatting from a + * string should typically be done by self::validate(). + * @note Handling of ParamValidator::PARAM_DEFAULT should be left to ParamValidator, + * as should PARAM_REQUIRED and the like. + * + * @param string $name Parameter name being fetched. + * @param array $settings Parameter settings array. + * @param array $options Options array. + * @return null|mixed Return null if the value wasn't present, otherwise a + * value to be passed to self::validate(). + */ + public function getValue( $name, array $settings, array $options ) { + return $this->callbacks->getValue( $name, null, $options ); + } + + /** + * Validate the value + * + * When ParamValidator is processing a multi-valued parameter, this will be + * called once for each of the supplied values. Which may mean zero calls. + * + * When getValue() returned null, this will not be called. + * + * @param string $name Parameter name being validated. + * @param mixed $value Value to validate, from getValue(). + * @param array $settings Parameter settings array. + * @param array $options Options array. Note the following values that may be set + * by ParamValidator: + * - values-list: (string[]) If defined, values of a multi-valued parameter are being processed + * (and this array holds the full set of values). + * @return mixed Validated value + * @throws ValidationException if the value is invalid + */ + abstract public function validate( $name, $value, array $settings, array $options ); + + /** + * Normalize a settings array + * @param array $settings + * @return array + */ + public function normalizeSettings( array $settings ) { + return $settings; + } + + /** + * Get the values for enum-like parameters + * + * This is primarily intended for documentation and implementation of + * PARAM_ALL; it is the responsibility of the TypeDef to ensure that validate() + * accepts the values returned here. + * + * @param string $name Parameter name being validated. + * @param array $settings Parameter settings array. + * @param array $options Options array. + * @return array|null All possible enumerated values, or null if this is + * not an enumeration. + */ + public function getEnumValues( $name, array $settings, array $options ) { + return null; + } + + /** + * Convert a value to a string representation. + * + * This is intended as the inverse of getValue() and validate(): this + * should accept anything returned by those methods or expected to be used + * as PARAM_DEFAULT, and if the string from this method is passed in as client + * input or PARAM_DEFAULT it should give equivalent output from validate(). + * + * @param string $name Parameter name being converted. + * @param mixed $value Parameter value being converted. Do not pass null. + * @param array $settings Parameter settings array. + * @param array $options Options array. + * @return string|null Return null if there is no representation of $value + * reasonably satisfying the description given. + */ + public function stringifyValue( $name, $value, array $settings, array $options ) { + return (string)$value; + } + + /** + * "Describe" a settings array + * + * This is intended to format data about a settings array using this type + * in a way that would be useful for automatically generated documentation + * or a machine-readable interface specification. + * + * Keys in the description array should follow the same guidelines as the + * code described for ValidationException. + * + * By default, each value in the description array is a single string, + * integer, or array. When `$options['compact']` is supplied, each value is + * instead an array of such and related values may be combined. For example, + * a non-compact description for an integer type might include + * `[ 'default' => 0, 'min' => 0, 'max' => 5 ]`, while in compact mode it might + * instead report `[ 'default' => [ 'value' => 0 ], 'minmax' => [ 'min' => 0, 'max' => 5 ] ]` + * to facilitate auto-generated documentation turning that 'minmax' into + * "Value must be between 0 and 5" rather than disconnected statements + * "Value must be >= 0" and "Value must be <= 5". + * + * @param string $name Parameter name being described. + * @param array $settings Parameter settings array. + * @param array $options Options array. Defined options for this base class are: + * - 'compact': (bool) Enable compact mode, as described above. + * @return array + */ + public function describeSettings( $name, array $settings, array $options ) { + $compact = !empty( $options['compact'] ); + + $ret = []; + + if ( isset( $settings[ParamValidator::PARAM_DEFAULT] ) ) { + $value = $this->stringifyValue( + $name, $settings[ParamValidator::PARAM_DEFAULT], $settings, $options + ); + $ret['default'] = $compact ? [ 'value' => $value ] : $value; + } + + return $ret; + } + +} diff --git a/includes/libs/ParamValidator/TypeDef/BooleanDef.php b/includes/libs/ParamValidator/TypeDef/BooleanDef.php new file mode 100644 index 0000000000..f77c930499 --- /dev/null +++ b/includes/libs/ParamValidator/TypeDef/BooleanDef.php @@ -0,0 +1,45 @@ + self::$TRUEVALS, + 'falsevals' => array_merge( self::$FALSEVALS, [ 'the empty string' ] ), + ] ); + } + + public function stringifyValue( $name, $value, array $settings, array $options ) { + return $value ? self::$TRUEVALS[0] : self::$FALSEVALS[0]; + } + +} diff --git a/includes/libs/ParamValidator/TypeDef/EnumDef.php b/includes/libs/ParamValidator/TypeDef/EnumDef.php new file mode 100644 index 0000000000..0f4f6908e5 --- /dev/null +++ b/includes/libs/ParamValidator/TypeDef/EnumDef.php @@ -0,0 +1,88 @@ +getEnumValues( $name, $settings, $options ); + + if ( in_array( $value, $values, true ) ) { + // Set a warning if a deprecated parameter value has been passed + if ( isset( $settings[self::PARAM_DEPRECATED_VALUES][$value] ) ) { + $this->callbacks->recordCondition( + new ValidationException( $name, $value, $settings, 'deprecated-value', [ + 'flag' => $settings[self::PARAM_DEPRECATED_VALUES][$value], + ] ), + $options + ); + } + + return $value; + } + + if ( !isset( $options['values-list'] ) && + count( ParamValidator::explodeMultiValue( $value, 2 ) ) > 1 + ) { + throw new ValidationException( $name, $value, $settings, 'notmulti', [] ); + } else { + throw new ValidationException( $name, $value, $settings, 'badvalue', [] ); + } + } + + public function getEnumValues( $name, array $settings, array $options ) { + return $settings[ParamValidator::PARAM_TYPE]; + } + + public function stringifyValue( $name, $value, array $settings, array $options ) { + if ( !is_array( $value ) ) { + return parent::stringifyValue( $name, $value, $settings, $options ); + } + + foreach ( $value as $v ) { + if ( strpos( $v, '|' ) !== false ) { + return "\x1f" . implode( "\x1f", $value ); + } + } + return implode( '|', $value ); + } + +} diff --git a/includes/libs/ParamValidator/TypeDef/FloatDef.php b/includes/libs/ParamValidator/TypeDef/FloatDef.php new file mode 100644 index 0000000000..0a204b3a88 --- /dev/null +++ b/includes/libs/ParamValidator/TypeDef/FloatDef.php @@ -0,0 +1,72 @@ + '.', + // PHP's number formatting currently uses only the first byte from 'decimal_point'. + // See upstream bug https://bugs.php.net/bug.php?id=78113 + $localeData['decimal_point'][0] => '.', + ] ); + } + return $value; + } + + public function stringifyValue( $name, $value, array $settings, array $options ) { + // Ensure sufficient precision for round-tripping. PHP_FLOAT_DIG was added in PHP 7.2. + $digits = defined( 'PHP_FLOAT_DIG' ) ? PHP_FLOAT_DIG : 15; + return $this->fixLocaleWeirdness( sprintf( "%.{$digits}g", $value ) ); + } + +} diff --git a/includes/libs/ParamValidator/TypeDef/IntegerDef.php b/includes/libs/ParamValidator/TypeDef/IntegerDef.php new file mode 100644 index 0000000000..556301b898 --- /dev/null +++ b/includes/libs/ParamValidator/TypeDef/IntegerDef.php @@ -0,0 +1,171 @@ + $max ) { + if ( $max2 !== null && $this->callbacks->useHighLimits( $options ) ) { + if ( $ret > $max2 ) { + $err = 'abovehighmaximum'; + $ret = $max2; + } + } else { + $err = 'abovemaximum'; + $ret = $max; + } + } + if ( $err !== null ) { + $ex = new ValidationException( $name, $value, $settings, $err, [ + 'min' => $min === null ? '' : $min, + 'max' => $max === null ? '' : $max, + 'max2' => $max2 === null ? '' : $max2, + ] ); + if ( empty( $settings[self::PARAM_IGNORE_RANGE] ) ) { + throw $ex; + } + $this->callbacks->recordCondition( $ex, $options ); + } + + return $ret; + } + + public function normalizeSettings( array $settings ) { + if ( !isset( $settings[self::PARAM_MAX] ) ) { + unset( $settings[self::PARAM_MAX2] ); + } + + if ( isset( $settings[self::PARAM_MAX2] ) && isset( $settings[self::PARAM_MAX] ) && + $settings[self::PARAM_MAX2] < $settings[self::PARAM_MAX] + ) { + $settings[self::PARAM_MAX2] = $settings[self::PARAM_MAX]; + } + + return parent::normalizeSettings( $settings ); + } + + public function describeSettings( $name, array $settings, array $options ) { + $info = parent::describeSettings( $name, $settings, $options ); + + $min = $settings[self::PARAM_MIN] ?? ''; + $max = $settings[self::PARAM_MAX] ?? ''; + $max2 = $settings[self::PARAM_MAX2] ?? ''; + if ( $max === '' || $max2 !== '' && $max2 <= $max ) { + $max2 = ''; + } + + if ( empty( $options['compact'] ) ) { + if ( $min !== '' ) { + $info['min'] = $min; + } + if ( $max !== '' ) { + $info['max'] = $max; + } + if ( $max2 !== '' ) { + $info['max2'] = $max2; + } + } else { + $key = ''; + if ( $min !== '' ) { + $key = 'min'; + } + if ( $max2 !== '' ) { + $key .= 'max2'; + } elseif ( $max !== '' ) { + $key .= 'max'; + } + if ( $key !== '' ) { + $info[$key] = [ 'min' => $min, 'max' => $max, 'max2' => $max2 ]; + } + } + + return $info; + } + +} diff --git a/includes/libs/ParamValidator/TypeDef/LimitDef.php b/includes/libs/ParamValidator/TypeDef/LimitDef.php new file mode 100644 index 0000000000..99780c4a0d --- /dev/null +++ b/includes/libs/ParamValidator/TypeDef/LimitDef.php @@ -0,0 +1,44 @@ +callbacks->useHighLimits( $options ) + ? $settings[self::PARAM_MAX2] ?? $settings[self::PARAM_MAX] ?? PHP_INT_MAX + : $settings[self::PARAM_MAX] ?? PHP_INT_MAX; + } + return $value; + } + + return parent::validate( $name, $value, $settings, $options ); + } + + public function normalizeSettings( array $settings ) { + $settings += [ + self::PARAM_MIN => 0, + ]; + + return parent::normalizeSettings( $settings ); + } + +} diff --git a/includes/libs/ParamValidator/TypeDef/PasswordDef.php b/includes/libs/ParamValidator/TypeDef/PasswordDef.php new file mode 100644 index 0000000000..289db54869 --- /dev/null +++ b/includes/libs/ParamValidator/TypeDef/PasswordDef.php @@ -0,0 +1,22 @@ +callbacks->hasParam( $name, $options ); + } + + public function validate( $name, $value, array $settings, array $options ) { + return (bool)$value; + } + + public function describeSettings( $name, array $settings, array $options ) { + $info = parent::describeSettings( $name, $settings, $options ); + unset( $info['default'] ); + return $info; + } + +} diff --git a/includes/libs/ParamValidator/TypeDef/StringDef.php b/includes/libs/ParamValidator/TypeDef/StringDef.php new file mode 100644 index 0000000000..0ed310b50f --- /dev/null +++ b/includes/libs/ParamValidator/TypeDef/StringDef.php @@ -0,0 +1,88 @@ +allowEmptyWhenRequired = !empty( $options['allowEmptyWhenRequired'] ); + } + + public function validate( $name, $value, array $settings, array $options ) { + if ( !$this->allowEmptyWhenRequired && $value === '' && + !empty( $settings[ParamValidator::PARAM_REQUIRED] ) + ) { + throw new ValidationException( $name, $value, $settings, 'missingparam', [] ); + } + + if ( isset( $settings[self::PARAM_MAX_BYTES] ) + && strlen( $value ) > $settings[self::PARAM_MAX_BYTES] + ) { + throw new ValidationException( $name, $value, $settings, 'maxbytes', [ + 'maxbytes' => $settings[self::PARAM_MAX_BYTES] ?? '', + 'maxchars' => $settings[self::PARAM_MAX_CHARS] ?? '', + ] ); + } + if ( isset( $settings[self::PARAM_MAX_CHARS] ) + && mb_strlen( $value, 'UTF-8' ) > $settings[self::PARAM_MAX_CHARS] + ) { + throw new ValidationException( $name, $value, $settings, 'maxchars', [ + 'maxbytes' => $settings[self::PARAM_MAX_BYTES] ?? '', + 'maxchars' => $settings[self::PARAM_MAX_CHARS] ?? '', + ] ); + } + + return $value; + } + +} diff --git a/includes/libs/ParamValidator/TypeDef/TimestampDef.php b/includes/libs/ParamValidator/TypeDef/TimestampDef.php new file mode 100644 index 0000000000..5d0bf4e951 --- /dev/null +++ b/includes/libs/ParamValidator/TypeDef/TimestampDef.php @@ -0,0 +1,100 @@ +defaultFormat = $options['defaultFormat'] ?? 'ConvertibleTimestamp'; + $this->stringifyFormat = $options['stringifyFormat'] ?? TS_ISO_8601; + } + + public function validate( $name, $value, array $settings, array $options ) { + // Confusing synonyms for the current time accepted by ConvertibleTimestamp + if ( !$value ) { + $this->callbacks->recordCondition( + new ValidationException( $name, $value, $settings, 'unclearnowtimestamp', [] ), + $options + ); + $value = 'now'; + } + + try { + $timestamp = new ConvertibleTimestamp( $value === 'now' ? false : $value ); + } catch ( TimestampException $ex ) { + throw new ValidationException( $name, $value, $settings, 'badtimestamp', [], $ex ); + } + + $format = $settings[self::PARAM_TIMESTAMP_FORMAT] ?? $this->defaultFormat; + switch ( $format ) { + case 'ConvertibleTimestamp': + return $timestamp; + + case 'DateTime': + // Eew, no getter. + return $timestamp->timestamp; + + default: + return $timestamp->getTimestamp( $format ); + } + } + + public function stringifyValue( $name, $value, array $settings, array $options ) { + if ( !$value instanceof ConvertibleTimestamp ) { + $value = new ConvertibleTimestamp( $value ); + } + return $value->getTimestamp( $this->stringifyFormat ); + } + +} diff --git a/includes/libs/ParamValidator/TypeDef/UploadDef.php b/includes/libs/ParamValidator/TypeDef/UploadDef.php new file mode 100644 index 0000000000..b436a6dc54 --- /dev/null +++ b/includes/libs/ParamValidator/TypeDef/UploadDef.php @@ -0,0 +1,116 @@ +callbacks->getUploadedFile( $name, $options ); + + if ( $ret && $ret->getError() === UPLOAD_ERR_NO_FILE && + !$this->callbacks->hasParam( $name, $options ) + ) { + // This seems to be that the client explicitly specified "no file" for the field + // instead of just omitting the field completely. DWTM. + $ret = null; + } elseif ( !$ret && $this->callbacks->hasParam( $name, $options ) ) { + // The client didn't format their upload properly so it came in as an ordinary + // field. Convert it to an error. + $ret = new UploadedFile( [ + 'name' => '', + 'type' => '', + 'tmp_name' => '', + 'error' => -42, // PHP's UPLOAD_ERR_* are all positive numbers. + 'size' => 0, + ] ); + } + + return $ret; + } + + /** + * Fetch the value of PHP's upload_max_filesize ini setting + * + * This method exists so it can be mocked by unit tests that can't + * affect ini_get() directly. + * + * @codeCoverageIgnore + * @return string|false + */ + protected function getIniSize() { + return ini_get( 'upload_max_filesize' ); + } + + public function validate( $name, $value, array $settings, array $options ) { + static $codemap = [ + -42 => 'notupload', // Local from getValue() + UPLOAD_ERR_FORM_SIZE => 'formsize', + UPLOAD_ERR_PARTIAL => 'partial', + UPLOAD_ERR_NO_FILE => 'nofile', + UPLOAD_ERR_NO_TMP_DIR => 'notmpdir', + UPLOAD_ERR_CANT_WRITE => 'cantwrite', + UPLOAD_ERR_EXTENSION => 'phpext', + ]; + + if ( !$value instanceof UploadedFileInterface ) { + // Err? + throw new ValidationException( $name, $value, $settings, 'badupload', [] ); + } + + $err = $value->getError(); + if ( $err === UPLOAD_ERR_OK ) { + return $value; + } elseif ( $err === UPLOAD_ERR_INI_SIZE ) { + static $prefixes = [ + 'g' => 1024 ** 3, + 'm' => 1024 ** 2, + 'k' => 1024 ** 1, + ]; + $size = $this->getIniSize(); + $last = strtolower( substr( $size, -1 ) ); + $size = intval( $size, 10 ) * ( $prefixes[$last] ?? 1 ); + throw new ValidationException( $name, $value, $settings, 'badupload-inisize', [ + 'size' => $size, + ] ); + } elseif ( isset( $codemap[$err] ) ) { + throw new ValidationException( $name, $value, $settings, 'badupload-' . $codemap[$err], [] ); + } else { + throw new ValidationException( $name, $value, $settings, 'badupload-unknown', [ + 'code' => $err, + ] ); + } + } + + public function stringifyValue( $name, $value, array $settings, array $options ) { + // Not going to happen. + return null; + } + +} diff --git a/includes/libs/ParamValidator/Util/UploadedFile.php b/includes/libs/ParamValidator/Util/UploadedFile.php new file mode 100644 index 0000000000..2be9119d25 --- /dev/null +++ b/includes/libs/ParamValidator/Util/UploadedFile.php @@ -0,0 +1,141 @@ +data = $data; + $this->fromUpload = $fromUpload; + } + + /** + * Throw if there was an error + * @throws RuntimeException + */ + private function checkError() { + switch ( $this->data['error'] ) { + case UPLOAD_ERR_OK: + break; + + case UPLOAD_ERR_INI_SIZE: + throw new RuntimeException( 'Upload exceeded maximum size' ); + + case UPLOAD_ERR_FORM_SIZE: + throw new RuntimeException( 'Upload exceeded form-specified maximum size' ); + + case UPLOAD_ERR_PARTIAL: + throw new RuntimeException( 'File was only partially uploaded' ); + + case UPLOAD_ERR_NO_FILE: + throw new RuntimeException( 'No file was uploaded' ); + + case UPLOAD_ERR_NO_TMP_DIR: + throw new RuntimeException( 'PHP has no temporary folder for storing uploaded files' ); + + case UPLOAD_ERR_CANT_WRITE: + throw new RuntimeException( 'PHP was unable to save the uploaded file' ); + + case UPLOAD_ERR_EXTENSION: + throw new RuntimeException( 'A PHP extension stopped the file upload' ); + + default: + throw new RuntimeException( 'Unknown upload error code ' . $this->data['error'] ); + } + + if ( $this->moved ) { + throw new RuntimeException( 'File has already been moved' ); + } + if ( !isset( $this->data['tmp_name'] ) || !file_exists( $this->data['tmp_name'] ) ) { + throw new RuntimeException( 'Uploaded file is missing' ); + } + } + + public function getStream() { + if ( $this->stream ) { + return $this->stream; + } + + $this->checkError(); + $this->stream = new UploadedFileStream( $this->data['tmp_name'] ); + return $this->stream; + } + + public function moveTo( $targetPath ) { + $this->checkError(); + + if ( $this->fromUpload && !is_uploaded_file( $this->data['tmp_name'] ) ) { + throw new RuntimeException( 'Specified file is not an uploaded file' ); + } + + // TODO remove the function_exists check once we drop HHVM support + if ( function_exists( 'error_clear_last' ) ) { + error_clear_last(); + } + $ret = AtEase::quietCall( + $this->fromUpload ? 'move_uploaded_file' : 'rename', + $this->data['tmp_name'], + $targetPath + ); + if ( $ret === false ) { + $err = error_get_last(); + throw new RuntimeException( "Move failed: " . ( $err['message'] ?? 'Unknown error' ) ); + } + + $this->moved = true; + if ( $this->stream ) { + $this->stream->close(); + $this->stream = null; + } + } + + public function getSize() { + return $this->data['size'] ?? null; + } + + public function getError() { + return $this->data['error'] ?? UPLOAD_ERR_NO_FILE; + } + + public function getClientFilename() { + $ret = $this->data['name'] ?? null; + return $ret === '' ? null : $ret; + } + + public function getClientMediaType() { + $ret = $this->data['type'] ?? null; + return $ret === '' ? null : $ret; + } + +} diff --git a/includes/libs/ParamValidator/Util/UploadedFileStream.php b/includes/libs/ParamValidator/Util/UploadedFileStream.php new file mode 100644 index 0000000000..17eaaf4a01 --- /dev/null +++ b/includes/libs/ParamValidator/Util/UploadedFileStream.php @@ -0,0 +1,168 @@ +fp = self::quietCall( 'fopen', [ $filename, 'r' ], false, 'Failed to open file' ); + } + + /** + * Check if the stream is open + * @throws RuntimeException if closed + */ + private function checkOpen() { + if ( !$this->fp ) { + throw new RuntimeException( 'Stream is not open' ); + } + } + + public function __destruct() { + $this->close(); + } + + public function __toString() { + try { + $this->seek( 0 ); + return $this->getContents(); + } catch ( Exception $ex ) { + // Not allowed to throw + return ''; + } catch ( Throwable $ex ) { + // Not allowed to throw + return ''; + } + } + + public function close() { + if ( $this->fp ) { + // Spec doesn't care about close errors. + AtEase::quietCall( 'fclose', $this->fp ); + $this->fp = null; + } + } + + public function detach() { + $ret = $this->fp; + $this->fp = null; + return $ret; + } + + public function getSize() { + if ( $this->size === false ) { + $this->size = null; + + if ( $this->fp ) { + // Spec doesn't care about errors here. + $stat = AtEase::quietCall( 'fstat', $this->fp ); + $this->size = $stat['size'] ?? null; + } + } + + return $this->size; + } + + public function tell() { + $this->checkOpen(); + return self::quietCall( 'ftell', [ $this->fp ], -1, 'Cannot determine stream position' ); + } + + public function eof() { + // Spec doesn't care about errors here. + return !$this->fp || AtEase::quietCall( 'feof', $this->fp ); + } + + public function isSeekable() { + return (bool)$this->fp; + } + + public function seek( $offset, $whence = SEEK_SET ) { + $this->checkOpen(); + self::quietCall( 'fseek', [ $this->fp, $offset, $whence ], -1, 'Seek failed' ); + } + + public function rewind() { + $this->seek( 0 ); + } + + public function isWritable() { + return false; + } + + public function write( $string ) { + $this->checkOpen(); + throw new RuntimeException( 'Stream is read-only' ); + } + + public function isReadable() { + return (bool)$this->fp; + } + + public function read( $length ) { + $this->checkOpen(); + return self::quietCall( 'fread', [ $this->fp, $length ], false, 'Read failed' ); + } + + public function getContents() { + $this->checkOpen(); + return self::quietCall( 'stream_get_contents', [ $this->fp ], false, 'Read failed' ); + } + + public function getMetadata( $key = null ) { + $this->checkOpen(); + $ret = self::quietCall( 'stream_get_meta_data', [ $this->fp ], false, 'Metadata fetch failed' ); + if ( $key !== null ) { + $ret = $ret[$key] ?? null; + } + return $ret; + } + +} diff --git a/includes/libs/ParamValidator/ValidationException.php b/includes/libs/ParamValidator/ValidationException.php new file mode 100644 index 0000000000..c8d995e0b9 --- /dev/null +++ b/includes/libs/ParamValidator/ValidationException.php @@ -0,0 +1,128 @@ +paramName = $name; + $this->paramValue = $value; + $this->settings = $settings; + $this->failureCode = $code; + $this->failureData = $data; + } + + /** + * Make a simple English message for the exception + * @param string $name + * @param string $code + * @param array $data + * @return string + */ + private static function formatMessage( $name, $code, $data ) { + $ret = "Validation of `$name` failed: $code"; + foreach ( $data as $k => $v ) { + if ( is_array( $v ) ) { + $v = implode( ', ', $v ); + } + $ret .= "; $k => $v"; + } + return $ret; + } + + /** + * Fetch the parameter name that failed validation + * @return string + */ + public function getParamName() { + return $this->paramName; + } + + /** + * Fetch the parameter value that failed validation + * @return mixed + */ + public function getParamValue() { + return $this->paramValue; + } + + /** + * Fetch the settings array that failed validation + * @return array + */ + public function getSettings() { + return $this->settings; + } + + /** + * Fetch the validation failure code + * + * A validation failure code is a reasonably short string matching the regex + * `/^[a-z][a-z0-9-]*$/`. + * + * Users are encouraged to use this with a suitable i18n mechanism rather + * than relying on the limited English text returned by getMessage(). + * + * @return string + */ + public function getFailureCode() { + return $this->failureCode; + } + + /** + * Fetch the validation failure data + * + * This returns additional data relevant to the particular failure code. + * + * Keys in the array are short ASCII strings. Values are strings or + * integers, or arrays of strings intended to be displayed as a + * comma-separated list. For any particular code the same keys are always + * returned in the same order, making it safe to use array_values() and + * access them positionally if that is desired. + * + * For example, the data for a hypothetical "integer-out-of-range" code + * might have data `[ 'min' => 0, 'max' => 100 ]` indicating the range of + * allowed values. + * + * @return (string|int|string[])[] + */ + public function getFailureData() { + return $this->failureData; + } + +} diff --git a/includes/libs/StatusValue.php b/includes/libs/StatusValue.php index 16cb1ed1d3..71a0e348dd 100644 --- a/includes/libs/StatusValue.php +++ b/includes/libs/StatusValue.php @@ -107,7 +107,7 @@ class StatusValue { } else { $errorsOnlyStatusValue->errors[] = $item; } - }; + } return [ $errorsOnlyStatusValue, $warningsOnlyStatusValue ]; } diff --git a/includes/libs/Xhprof.php b/includes/libs/Xhprof.php index 8175427d8d..a1ddfd0772 100644 --- a/includes/libs/Xhprof.php +++ b/includes/libs/Xhprof.php @@ -77,6 +77,8 @@ class Xhprof { 'tideways_disable', 'tideways_xhprof_disable' ] ); + } else { + return null; } } @@ -84,6 +86,7 @@ class Xhprof { * Call the first available function from $functions. * @param array $functions * @param array $args + * @return mixed * @throws Exception */ protected static function callAny( array $functions, array $args = [] ) { diff --git a/includes/libs/filebackend/FileBackend.php b/includes/libs/filebackend/FileBackend.php index 53a0ca040f..4ad48c70c1 100644 --- a/includes/libs/filebackend/FileBackend.php +++ b/includes/libs/filebackend/FileBackend.php @@ -30,6 +30,7 @@ use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerInterface; use Wikimedia\ScopedCallback; +use Psr\Log\NullLogger; /** * @brief Base class for all file backend classes (including multi-write backends). @@ -190,7 +191,7 @@ abstract class FileBackend implements LoggerAwareInterface { if ( !is_callable( $this->profiler ) ) { $this->profiler = null; } - $this->logger = $config['logger'] ?? new \Psr\Log\NullLogger(); + $this->logger = $config['logger'] ?? new NullLogger(); $this->statusWrapper = $config['statusWrapper'] ?? null; $this->tmpDirectory = $config['tmpDirectory'] ?? null; } diff --git a/includes/libs/filebackend/FileBackendStore.php b/includes/libs/filebackend/FileBackendStore.php index 3663637747..e2a25fcd51 100644 --- a/includes/libs/filebackend/FileBackendStore.php +++ b/includes/libs/filebackend/FileBackendStore.php @@ -747,7 +747,7 @@ abstract class FileBackendStore extends FileBackend { /** * @see FileBackendStore::getFileXAttributes() * @param array $params - * @return array[][] + * @return array[][]|false */ protected function doGetFileXAttributes( array $params ) { return [ 'headers' => [], 'metadata' => [] ]; // not supported diff --git a/includes/libs/filebackend/HTTPFileStreamer.php b/includes/libs/filebackend/HTTPFileStreamer.php index e1612546d6..7a11aebf7e 100644 --- a/includes/libs/filebackend/HTTPFileStreamer.php +++ b/includes/libs/filebackend/HTTPFileStreamer.php @@ -39,6 +39,27 @@ class HTTPFileStreamer { // Do not try to tear down any PHP output buffers const STREAM_ALLOW_OB = 2; + /** + * Takes HTTP headers in a name => value format and converts them to the weird format + * expected by stream(). + * @param string[] $headers + * @return array[] [ $headers, $optHeaders ] + * @since 1.34 + */ + public static function preprocessHeaders( $headers ) { + $rawHeaders = []; + $optHeaders = []; + foreach ( $headers as $name => $header ) { + $nameLower = strtolower( $name ); + if ( in_array( $nameLower, [ 'range', 'if-modified-since' ], true ) ) { + $optHeaders[$nameLower] = $header; + } else { + $rawHeaders[] = "$name: $header"; + } + } + return [ $rawHeaders, $optHeaders ]; + } + /** * @param string $path Local filesystem path to a file * @param array $params Options map, which includes: diff --git a/includes/libs/filebackend/SwiftFileBackend.php b/includes/libs/filebackend/SwiftFileBackend.php index dc5aa22e78..a1b2460df6 100644 --- a/includes/libs/filebackend/SwiftFileBackend.php +++ b/includes/libs/filebackend/SwiftFileBackend.php @@ -297,7 +297,7 @@ class SwiftFileBackend extends FileBackendStore { $method = __METHOD__; $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) { list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $request['response']; - if ( $rcode === 201 ) { + if ( $rcode === 201 || $rcode === 202 ) { // good } elseif ( $rcode === 412 ) { $status->fatal( 'backend-fail-contenttype', $params['dst'] ); @@ -360,7 +360,7 @@ class SwiftFileBackend extends FileBackendStore { $method = __METHOD__; $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) { list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $request['response']; - if ( $rcode === 201 ) { + if ( $rcode === 201 || $rcode === 202 ) { // good } elseif ( $rcode === 412 ) { $status->fatal( 'backend-fail-contenttype', $params['dst'] ); diff --git a/includes/libs/filebackend/filejournal/FileJournal.php b/includes/libs/filebackend/filejournal/FileJournal.php index 999594b85e..dc007a0cec 100644 --- a/includes/libs/filebackend/filejournal/FileJournal.php +++ b/includes/libs/filebackend/filejournal/FileJournal.php @@ -26,6 +26,8 @@ * @ingroup FileJournal */ +use Wikimedia\Timestamp\ConvertibleTimestamp; + /** * @brief Class for handling file operation journaling. * @@ -37,7 +39,6 @@ abstract class FileJournal { /** @var string */ protected $backend; - /** @var int */ protected $ttlDays; @@ -63,7 +64,7 @@ abstract class FileJournal { $class = $config['class']; $jrn = new $class( $config ); if ( !$jrn instanceof self ) { - throw new InvalidArgumentException( "Class given is not an instance of FileJournal." ); + throw new InvalidArgumentException( "$class is not an instance of " . __CLASS__ ); } $jrn->backend = $backend; @@ -82,7 +83,9 @@ abstract class FileJournal { } $s = Wikimedia\base_convert( sha1( $s ), 16, 36, 31 ); - return substr( Wikimedia\base_convert( wfTimestamp( TS_MW ), 10, 36, 9 ) . $s, 0, 31 ); + $timestamp = ConvertibleTimestamp::convert( TS_MW, time() ); + + return substr( Wikimedia\base_convert( $timestamp, 10, 36, 9 ) . $s, 0, 31 ); } /** diff --git a/includes/libs/http/MultiHttpClient.php b/includes/libs/http/MultiHttpClient.php new file mode 100644 index 0000000000..2e418b96b9 --- /dev/null +++ b/includes/libs/http/MultiHttpClient.php @@ -0,0 +1,609 @@ + (uses RFC 3986) + * - headers :
+ * - body : source to get the HTTP request body from; + * this can simply be a string (always), a resource for + * PUT requests, and a field/value array for POST request; + * array bodies are encoded as multipart/form-data and strings + * use application/x-www-form-urlencoded (headers sent automatically) + * - stream : resource to stream the HTTP response body to + * - proxy : HTTP proxy to use + * - flags : map of boolean flags which supports: + * - relayResponseHeaders : write out header via header() + * Request maps can use integer index 0 instead of 'method' and 1 instead of 'url'. + * + * @since 1.23 + */ +class MultiHttpClient implements LoggerAwareInterface { + /** @var resource */ + protected $multiHandle = null; // curl_multi handle + /** @var string|null SSL certificates path */ + protected $caBundlePath; + /** @var float */ + protected $connTimeout = 10; + /** @var float */ + protected $reqTimeout = 900; + /** @var bool */ + protected $usePipelining = false; + /** @var int */ + protected $maxConnsPerHost = 50; + /** @var string|null proxy */ + protected $proxy; + /** @var string */ + protected $userAgent = 'wikimedia/multi-http-client v1.0'; + /** @var LoggerInterface */ + protected $logger; + + // In PHP 7 due to https://bugs.php.net/bug.php?id=76480 the request/connect + // timeouts are periodically polled instead of being accurately respected. + // The select timeout is set to the minimum timeout multiplied by this factor. + const TIMEOUT_ACCURACY_FACTOR = 0.1; + + /** + * @param array $options + * - connTimeout : default connection timeout (seconds) + * - reqTimeout : default request timeout (seconds) + * - proxy : HTTP proxy to use + * - usePipelining : whether to use HTTP pipelining if possible (for all hosts) + * - maxConnsPerHost : maximum number of concurrent connections (per host) + * - userAgent : The User-Agent header value to send + * - logger : a \Psr\Log\LoggerInterface instance for debug logging + * - caBundlePath : path to specific Certificate Authority bundle (if any) + * @throws Exception + */ + public function __construct( array $options ) { + if ( isset( $options['caBundlePath'] ) ) { + $this->caBundlePath = $options['caBundlePath']; + if ( !file_exists( $this->caBundlePath ) ) { + throw new Exception( "Cannot find CA bundle: " . $this->caBundlePath ); + } + } + static $opts = [ + 'connTimeout', 'reqTimeout', 'usePipelining', 'maxConnsPerHost', + 'proxy', 'userAgent', 'logger' + ]; + foreach ( $opts as $key ) { + if ( isset( $options[$key] ) ) { + $this->$key = $options[$key]; + } + } + if ( $this->logger === null ) { + $this->logger = new NullLogger; + } + } + + /** + * Execute an HTTP(S) request + * + * This method returns a response map of: + * - code : HTTP response code or 0 if there was a serious error + * - reason : HTTP response reason (empty if there was a serious error) + * - headers :
+ * - body : HTTP response body or resource (if "stream" was set) + * - error : Any error string + * The map also stores integer-indexed copies of these values. This lets callers do: + * @code + * list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $http->run( $req ); + * @endcode + * @param array $req HTTP request array + * @param array $opts + * - connTimeout : connection timeout per request (seconds) + * - reqTimeout : post-connection timeout per request (seconds) + * @return array Response array for request + */ + public function run( array $req, array $opts = [] ) { + return $this->runMulti( [ $req ], $opts )[0]['response']; + } + + /** + * Execute a set of HTTP(S) requests. + * + * If curl is available, requests will be made concurrently. + * Otherwise, they will be made serially. + * + * The maps are returned by this method with the 'response' field set to a map of: + * - code : HTTP response code or 0 if there was a serious error + * - reason : HTTP response reason (empty if there was a serious error) + * - headers :
+ * - body : HTTP response body or resource (if "stream" was set) + * - error : Any error string + * The map also stores integer-indexed copies of these values. This lets callers do: + * @code + * list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $req['response']; + * @endcode + * All headers in the 'headers' field are normalized to use lower case names. + * This is true for the request headers and the response headers. Integer-indexed + * method/URL entries will also be changed to use the corresponding string keys. + * + * @param array $reqs Map of HTTP request arrays + * @param array $opts + * - connTimeout : connection timeout per request (seconds) + * - reqTimeout : post-connection timeout per request (seconds) + * - usePipelining : whether to use HTTP pipelining if possible + * - maxConnsPerHost : maximum number of concurrent connections (per host) + * @return array $reqs With response array populated for each + * @throws Exception + */ + public function runMulti( array $reqs, array $opts = [] ) { + $this->normalizeRequests( $reqs ); + if ( $this->isCurlEnabled() ) { + return $this->runMultiCurl( $reqs, $opts ); + } else { + return $this->runMultiHttp( $reqs, $opts ); + } + } + + /** + * Determines if the curl extension is available + * + * @return bool true if curl is available, false otherwise. + */ + protected function isCurlEnabled() { + return extension_loaded( 'curl' ); + } + + /** + * Execute a set of HTTP(S) requests concurrently + * + * @see MultiHttpClient::runMulti() + * + * @param array $reqs Map of HTTP request arrays + * @param array $opts + * - connTimeout : connection timeout per request (seconds) + * - reqTimeout : post-connection timeout per request (seconds) + * - usePipelining : whether to use HTTP pipelining if possible + * - maxConnsPerHost : maximum number of concurrent connections (per host) + * @return array $reqs With response array populated for each + * @throws Exception + */ + private function runMultiCurl( array $reqs, array $opts = [] ) { + $chm = $this->getCurlMulti(); + + $selectTimeout = $this->getSelectTimeout( $opts ); + + // Add all of the required cURL handles... + $handles = []; + foreach ( $reqs as $index => &$req ) { + $handles[$index] = $this->getCurlHandle( $req, $opts ); + if ( count( $reqs ) > 1 ) { + // https://github.com/guzzle/guzzle/issues/349 + curl_setopt( $handles[$index], CURLOPT_FORBID_REUSE, true ); + } + } + unset( $req ); // don't assign over this by accident + + $indexes = array_keys( $reqs ); + if ( isset( $opts['usePipelining'] ) ) { + curl_multi_setopt( $chm, CURLMOPT_PIPELINING, (int)$opts['usePipelining'] ); + } + if ( isset( $opts['maxConnsPerHost'] ) ) { + // Keep these sockets around as they may be needed later in the request + curl_multi_setopt( $chm, CURLMOPT_MAXCONNECTS, (int)$opts['maxConnsPerHost'] ); + } + + // @TODO: use a per-host rolling handle window (e.g. CURLMOPT_MAX_HOST_CONNECTIONS) + $batches = array_chunk( $indexes, $this->maxConnsPerHost ); + $infos = []; + + foreach ( $batches as $batch ) { + // Attach all cURL handles for this batch + foreach ( $batch as $index ) { + curl_multi_add_handle( $chm, $handles[$index] ); + } + // Execute the cURL handles concurrently... + $active = null; // handles still being processed + do { + // Do any available work... + do { + $mrc = curl_multi_exec( $chm, $active ); + $info = curl_multi_info_read( $chm ); + if ( $info !== false ) { + $infos[(int)$info['handle']] = $info; + } + } while ( $mrc == CURLM_CALL_MULTI_PERFORM ); + // Wait (if possible) for available work... + if ( $active > 0 && $mrc == CURLM_OK && curl_multi_select( $chm, $selectTimeout ) == -1 ) { + // PHP bug 63411; https://curl.haxx.se/libcurl/c/curl_multi_fdset.html + usleep( 5000 ); // 5ms + } + } while ( $active > 0 && $mrc == CURLM_OK ); + } + + // Remove all of the added cURL handles and check for errors... + foreach ( $reqs as $index => &$req ) { + $ch = $handles[$index]; + curl_multi_remove_handle( $chm, $ch ); + + if ( isset( $infos[(int)$ch] ) ) { + $info = $infos[(int)$ch]; + $errno = $info['result']; + if ( $errno !== 0 ) { + $req['response']['error'] = "(curl error: $errno)"; + if ( function_exists( 'curl_strerror' ) ) { + $req['response']['error'] .= " " . curl_strerror( $errno ); + } + $this->logger->warning( "Error fetching URL \"{$req['url']}\": " . + $req['response']['error'] ); + } + } else { + $req['response']['error'] = "(curl error: no status set)"; + } + + // For convenience with the list() operator + $req['response'][0] = $req['response']['code']; + $req['response'][1] = $req['response']['reason']; + $req['response'][2] = $req['response']['headers']; + $req['response'][3] = $req['response']['body']; + $req['response'][4] = $req['response']['error']; + curl_close( $ch ); + // Close any string wrapper file handles + if ( isset( $req['_closeHandle'] ) ) { + fclose( $req['_closeHandle'] ); + unset( $req['_closeHandle'] ); + } + } + unset( $req ); // don't assign over this by accident + + // Restore the default settings + curl_multi_setopt( $chm, CURLMOPT_PIPELINING, (int)$this->usePipelining ); + curl_multi_setopt( $chm, CURLMOPT_MAXCONNECTS, (int)$this->maxConnsPerHost ); + + return $reqs; + } + + /** + * @param array &$req HTTP request map + * @param array $opts + * - connTimeout : default connection timeout + * - reqTimeout : default request timeout + * @return resource + * @throws Exception + */ + protected function getCurlHandle( array &$req, array $opts = [] ) { + $ch = curl_init(); + + curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT_MS, + ( $opts['connTimeout'] ?? $this->connTimeout ) * 1000 ); + curl_setopt( $ch, CURLOPT_PROXY, $req['proxy'] ?? $this->proxy ); + curl_setopt( $ch, CURLOPT_TIMEOUT_MS, + ( $opts['reqTimeout'] ?? $this->reqTimeout ) * 1000 ); + curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, 1 ); + curl_setopt( $ch, CURLOPT_MAXREDIRS, 4 ); + curl_setopt( $ch, CURLOPT_HEADER, 0 ); + if ( !is_null( $this->caBundlePath ) ) { + curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, true ); + curl_setopt( $ch, CURLOPT_CAINFO, $this->caBundlePath ); + } + curl_setopt( $ch, CURLOPT_RETURNTRANSFER, 1 ); + + $url = $req['url']; + $query = http_build_query( $req['query'], '', '&', PHP_QUERY_RFC3986 ); + if ( $query != '' ) { + $url .= strpos( $req['url'], '?' ) === false ? "?$query" : "&$query"; + } + curl_setopt( $ch, CURLOPT_URL, $url ); + + curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, $req['method'] ); + if ( $req['method'] === 'HEAD' ) { + curl_setopt( $ch, CURLOPT_NOBODY, 1 ); + } + + if ( $req['method'] === 'PUT' ) { + curl_setopt( $ch, CURLOPT_PUT, 1 ); + if ( is_resource( $req['body'] ) ) { + curl_setopt( $ch, CURLOPT_INFILE, $req['body'] ); + if ( isset( $req['headers']['content-length'] ) ) { + curl_setopt( $ch, CURLOPT_INFILESIZE, $req['headers']['content-length'] ); + } elseif ( isset( $req['headers']['transfer-encoding'] ) && + $req['headers']['transfer-encoding'] === 'chunks' + ) { + curl_setopt( $ch, CURLOPT_UPLOAD, true ); + } else { + throw new Exception( "Missing 'Content-Length' or 'Transfer-Encoding' header." ); + } + } elseif ( $req['body'] !== '' ) { + $fp = fopen( "php://temp", "wb+" ); + fwrite( $fp, $req['body'], strlen( $req['body'] ) ); + rewind( $fp ); + curl_setopt( $ch, CURLOPT_INFILE, $fp ); + curl_setopt( $ch, CURLOPT_INFILESIZE, strlen( $req['body'] ) ); + $req['_closeHandle'] = $fp; // remember to close this later + } else { + curl_setopt( $ch, CURLOPT_INFILESIZE, 0 ); + } + curl_setopt( $ch, CURLOPT_READFUNCTION, + function ( $ch, $fd, $length ) { + $data = fread( $fd, $length ); + $len = strlen( $data ); + return $data; + } + ); + } elseif ( $req['method'] === 'POST' ) { + curl_setopt( $ch, CURLOPT_POST, 1 ); + // Don't interpret POST parameters starting with '@' as file uploads, because this + // makes it impossible to POST plain values starting with '@' (and causes security + // issues potentially exposing the contents of local files). + curl_setopt( $ch, CURLOPT_SAFE_UPLOAD, true ); + curl_setopt( $ch, CURLOPT_POSTFIELDS, $req['body'] ); + } else { + if ( is_resource( $req['body'] ) || $req['body'] !== '' ) { + throw new Exception( "HTTP body specified for a non PUT/POST request." ); + } + $req['headers']['content-length'] = 0; + } + + if ( !isset( $req['headers']['user-agent'] ) ) { + $req['headers']['user-agent'] = $this->userAgent; + } + + $headers = []; + foreach ( $req['headers'] as $name => $value ) { + if ( strpos( $name, ': ' ) ) { + throw new Exception( "Headers cannot have ':' in the name." ); + } + $headers[] = $name . ': ' . trim( $value ); + } + curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers ); + + curl_setopt( $ch, CURLOPT_HEADERFUNCTION, + function ( $ch, $header ) use ( &$req ) { + if ( !empty( $req['flags']['relayResponseHeaders'] ) && trim( $header ) !== '' ) { + header( $header ); + } + $length = strlen( $header ); + $matches = []; + if ( preg_match( "/^(HTTP\/1\.[01]) (\d{3}) (.*)/", $header, $matches ) ) { + $req['response']['code'] = (int)$matches[2]; + $req['response']['reason'] = trim( $matches[3] ); + return $length; + } + if ( strpos( $header, ":" ) === false ) { + return $length; + } + list( $name, $value ) = explode( ":", $header, 2 ); + $name = strtolower( $name ); + $value = trim( $value ); + if ( isset( $req['response']['headers'][$name] ) ) { + $req['response']['headers'][$name] .= ', ' . $value; + } else { + $req['response']['headers'][$name] = $value; + } + return $length; + } + ); + + if ( isset( $req['stream'] ) ) { + // Don't just use CURLOPT_FILE as that might give: + // curl_setopt(): cannot represent a stream of type Output as a STDIO FILE* + // The callback here handles both normal files and php://temp handles. + curl_setopt( $ch, CURLOPT_WRITEFUNCTION, + function ( $ch, $data ) use ( &$req ) { + return fwrite( $req['stream'], $data ); + } + ); + } else { + curl_setopt( $ch, CURLOPT_WRITEFUNCTION, + function ( $ch, $data ) use ( &$req ) { + $req['response']['body'] .= $data; + return strlen( $data ); + } + ); + } + + return $ch; + } + + /** + * @return resource + * @throws Exception + */ + protected function getCurlMulti() { + if ( !$this->multiHandle ) { + if ( !function_exists( 'curl_multi_init' ) ) { + throw new Exception( "PHP cURL function curl_multi_init missing. " . + "Check https://www.mediawiki.org/wiki/Manual:CURL" ); + } + $cmh = curl_multi_init(); + curl_multi_setopt( $cmh, CURLMOPT_PIPELINING, (int)$this->usePipelining ); + curl_multi_setopt( $cmh, CURLMOPT_MAXCONNECTS, (int)$this->maxConnsPerHost ); + $this->multiHandle = $cmh; + } + return $this->multiHandle; + } + + /** + * Execute a set of HTTP(S) requests sequentially. + * + * @see MultiHttpClient::runMulti() + * @todo Remove dependency on MediaWikiServices: use a separate HTTP client + * library or copy code from PhpHttpRequest + * @param array $reqs Map of HTTP request arrays + * @param array $opts + * - connTimeout : connection timeout per request (seconds) + * - reqTimeout : post-connection timeout per request (seconds) + * @return array $reqs With response array populated for each + * @throws Exception + */ + private function runMultiHttp( array $reqs, array $opts = [] ) { + $httpOptions = [ + 'timeout' => $opts['reqTimeout'] ?? $this->reqTimeout, + 'connectTimeout' => $opts['connTimeout'] ?? $this->connTimeout, + 'logger' => $this->logger, + 'caInfo' => $this->caBundlePath, + ]; + foreach ( $reqs as &$req ) { + $reqOptions = $httpOptions + [ + 'method' => $req['method'], + 'proxy' => $req['proxy'] ?? $this->proxy, + 'userAgent' => $req['headers']['user-agent'] ?? $this->userAgent, + 'postData' => $req['body'], + ]; + + $url = $req['url']; + $query = http_build_query( $req['query'], '', '&', PHP_QUERY_RFC3986 ); + if ( $query != '' ) { + $url .= strpos( $req['url'], '?' ) === false ? "?$query" : "&$query"; + } + + $httpRequest = MediaWikiServices::getInstance()->getHttpRequestFactory()->create( + $url, $reqOptions ); + $sv = $httpRequest->execute()->getStatusValue(); + + $respHeaders = array_map( + function ( $v ) { + return implode( ', ', $v ); + }, + $httpRequest->getResponseHeaders() ); + + $req['response'] = [ + 'code' => $httpRequest->getStatus(), + 'reason' => '', + 'headers' => $respHeaders, + 'body' => $httpRequest->getContent(), + 'error' => '', + ]; + + if ( !$sv->isOk() ) { + $svErrors = $sv->getErrors(); + if ( isset( $svErrors[0] ) ) { + $req['response']['error'] = $svErrors[0]['message']; + + // param values vary per failure type (ex. unknown host vs unknown page) + if ( isset( $svErrors[0]['params'][0] ) ) { + if ( is_numeric( $svErrors[0]['params'][0] ) ) { + if ( isset( $svErrors[0]['params'][1] ) ) { + $req['response']['reason'] = $svErrors[0]['params'][1]; + } + } else { + $req['response']['reason'] = $svErrors[0]['params'][0]; + } + } + } + } + + $req['response'][0] = $req['response']['code']; + $req['response'][1] = $req['response']['reason']; + $req['response'][2] = $req['response']['headers']; + $req['response'][3] = $req['response']['body']; + $req['response'][4] = $req['response']['error']; + } + + return $reqs; + } + + /** + * Normalize request information + * + * @param array $reqs the requests to normalize + */ + private function normalizeRequests( array &$reqs ) { + foreach ( $reqs as &$req ) { + $req['response'] = [ + 'code' => 0, + 'reason' => '', + 'headers' => [], + 'body' => '', + 'error' => '' + ]; + if ( isset( $req[0] ) ) { + $req['method'] = $req[0]; // short-form + unset( $req[0] ); + } + if ( isset( $req[1] ) ) { + $req['url'] = $req[1]; // short-form + unset( $req[1] ); + } + if ( !isset( $req['method'] ) ) { + throw new Exception( "Request has no 'method' field set." ); + } elseif ( !isset( $req['url'] ) ) { + throw new Exception( "Request has no 'url' field set." ); + } + $this->logger->debug( "{$req['method']}: {$req['url']}" ); + $req['query'] = $req['query'] ?? []; + $headers = []; // normalized headers + if ( isset( $req['headers'] ) ) { + foreach ( $req['headers'] as $name => $value ) { + $headers[strtolower( $name )] = $value; + } + } + $req['headers'] = $headers; + if ( !isset( $req['body'] ) ) { + $req['body'] = ''; + $req['headers']['content-length'] = 0; + } + $req['flags'] = $req['flags'] ?? []; + } + } + + /** + * Get a suitable select timeout for the given options. + * + * @param array $opts + * @return float + */ + private function getSelectTimeout( $opts ) { + $connTimeout = $opts['connTimeout'] ?? $this->connTimeout; + $reqTimeout = $opts['reqTimeout'] ?? $this->reqTimeout; + $timeouts = array_filter( [ $connTimeout, $reqTimeout ] ); + if ( count( $timeouts ) === 0 ) { + return 1; + } + + $selectTimeout = min( $timeouts ) * self::TIMEOUT_ACCURACY_FACTOR; + // Minimum 10us for sanity + if ( $selectTimeout < 10e-6 ) { + $selectTimeout = 10e-6; + } + return $selectTimeout; + } + + /** + * Register a logger + * + * @param LoggerInterface $logger + */ + public function setLogger( LoggerInterface $logger ) { + $this->logger = $logger; + } + + function __destruct() { + if ( $this->multiHandle ) { + curl_multi_close( $this->multiHandle ); + } + } +} diff --git a/includes/libs/lockmanager/FSLockManager.php b/includes/libs/lockmanager/FSLockManager.php index 019029c480..c00b041b33 100644 --- a/includes/libs/lockmanager/FSLockManager.php +++ b/includes/libs/lockmanager/FSLockManager.php @@ -124,9 +124,13 @@ class FSLockManager extends LockManager { } else { Wikimedia\suppressWarnings(); $handle = fopen( $this->getLockPath( $path ), 'a+' ); - if ( !$handle ) { // lock dir missing? - mkdir( $this->lockDir, 0777, true ); - $handle = fopen( $this->getLockPath( $path ), 'a+' ); // try again + if ( !$handle && !is_dir( $this->lockDir ) ) { + // Create the lock directory in case it is missing + if ( mkdir( $this->lockDir, 0777, true ) ) { + $handle = fopen( $this->getLockPath( $path ), 'a+' ); // try again + } else { + $this->logger->error( "Cannot create directory '{$this->lockDir}'." ); + } } Wikimedia\restoreWarnings(); } diff --git a/includes/libs/lockmanager/LockManager.php b/includes/libs/lockmanager/LockManager.php index d152c65ec0..b8d3ad2c60 100644 --- a/includes/libs/lockmanager/LockManager.php +++ b/includes/libs/lockmanager/LockManager.php @@ -4,6 +4,7 @@ * @ingroup FileBackend */ use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; use Wikimedia\WaitConditionLoop; /** @@ -101,7 +102,7 @@ abstract class LockManager { } $this->session = md5( implode( '-', $random ) ); - $this->logger = $config['logger'] ?? new \Psr\Log\NullLogger(); + $this->logger = $config['logger'] ?? new NullLogger(); } /** diff --git a/includes/libs/lockmanager/NullLockManager.php b/includes/libs/lockmanager/NullLockManager.php index b83462c798..339a7ee43a 100644 --- a/includes/libs/lockmanager/NullLockManager.php +++ b/includes/libs/lockmanager/NullLockManager.php @@ -22,15 +22,38 @@ */ /** - * Simple version of LockManager that does nothing + * Simple version of LockManager that only does lock reference counting * @since 1.19 */ class NullLockManager extends LockManager { protected function doLock( array $paths, $type ) { + foreach ( $paths as $path ) { + if ( isset( $this->locksHeld[$path][$type] ) ) { + ++$this->locksHeld[$path][$type]; + } else { + $this->locksHeld[$path][$type] = 1; + } + } + return StatusValue::newGood(); } protected function doUnlock( array $paths, $type ) { - return StatusValue::newGood(); + $status = StatusValue::newGood(); + + foreach ( $paths as $path ) { + if ( isset( $this->locksHeld[$path][$type] ) ) { + if ( --$this->locksHeld[$path][$type] <= 0 ) { + unset( $this->locksHeld[$path][$type] ); + if ( !$this->locksHeld[$path] ) { + unset( $this->locksHeld[$path] ); // clean up + } + } + } else { + $status->warning( 'lockmanager-notlocked', $path ); + } + } + + return $status; } } diff --git a/includes/libs/lockmanager/QuorumLockManager.php b/includes/libs/lockmanager/QuorumLockManager.php index 1ef4642a84..950b283670 100644 --- a/includes/libs/lockmanager/QuorumLockManager.php +++ b/includes/libs/lockmanager/QuorumLockManager.php @@ -35,15 +35,7 @@ abstract class QuorumLockManager extends LockManager { /** @var array Map of degraded buckets */ protected $degradedBuckets = []; // (bucket index => UNIX timestamp) - final protected function doLock( array $paths, $type ) { - return $this->doLockByType( [ $type => $paths ] ); - } - - final protected function doUnlock( array $paths, $type ) { - return $this->doUnlockByType( [ $type => $paths ] ); - } - - protected function doLockByType( array $pathsByType ) { + final protected function doLockByType( array $pathsByType ) { $status = StatusValue::newGood(); $pathsToLock = []; // (bucket => type => paths) @@ -278,4 +270,12 @@ abstract class QuorumLockManager extends LockManager { * @return StatusValue */ abstract protected function releaseAllLocks(); + + final protected function doLock( array $paths, $type ) { + throw new LogicException( __METHOD__ . ': proxy class does not need this method.' ); + } + + final protected function doUnlock( array $paths, $type ) { + throw new LogicException( __METHOD__ . ': proxy class does not need this method.' ); + } } diff --git a/includes/libs/mime/MSCompoundFileReader.php b/includes/libs/mime/MSCompoundFileReader.php index aea0a02f60..8afaa38e02 100644 --- a/includes/libs/mime/MSCompoundFileReader.php +++ b/includes/libs/mime/MSCompoundFileReader.php @@ -333,7 +333,7 @@ class MSCompoundFileReader { continue; } - $name = iconv( 'UTF-16', 'UTF-8', substr( $entry['name_raw'], 0, $entry['name_length'] - 2 ) ); + $name = iconv( 'UTF-16LE', 'UTF-8', substr( $entry['name_raw'], 0, $entry['name_length'] - 2 ) ); $clsid = $this->decodeClsid( $entry['clsid'] ); if ( $type == self::TYPE_ROOT && isset( self::$mimesByClsid[$clsid] ) ) { diff --git a/includes/libs/mime/MimeAnalyzer.php b/includes/libs/mime/MimeAnalyzer.php index a326df2bc2..bafe5e3098 100644 --- a/includes/libs/mime/MimeAnalyzer.php +++ b/includes/libs/mime/MimeAnalyzer.php @@ -21,6 +21,7 @@ */ use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; /** * Implements functions related to MIME types such as detection and mapping to file extension @@ -199,7 +200,7 @@ EOT; $this->detectCallback = $params['detectCallback'] ?? null; $this->guessCallback = $params['guessCallback'] ?? null; $this->extCallback = $params['extCallback'] ?? null; - $this->logger = $params['logger'] ?? new \Psr\Log\NullLogger(); + $this->logger = $params['logger'] ?? new NullLogger(); $this->loadFiles(); } @@ -755,7 +756,9 @@ EOT; /** * look for XML formats (XHTML and SVG) */ + Wikimedia\suppressWarnings(); $xml = new XmlTypeCheck( $file ); + Wikimedia\restoreWarnings(); if ( $xml->wellFormed ) { $xmlTypes = $this->xmlTypes; return $xmlTypes[$xml->getRootElement()] ?? 'application/xml'; @@ -803,10 +806,10 @@ EOT; // Check for ZIP variants (before getimagesize) $eocdrPos = strpos( $tail, "PK\x05\x06" ); - if ( $eocdrPos !== false ) { + if ( $eocdrPos !== false && $eocdrPos <= strlen( $tail ) - 22 ) { $this->logger->info( __METHOD__ . ": ZIP signature present in $file\n" ); // Check if it really is a ZIP file, make sure the EOCDR is at the end (T40432) - $commentLength = unpack( "n", substr( $tail, $eocdrPos + 20 ) )[0]; + $commentLength = unpack( "n", substr( $tail, $eocdrPos + 20 ) )[1]; if ( $eocdrPos + 22 + $commentLength !== strlen( $tail ) ) { $this->logger->info( __METHOD__ . ": ZIP EOCDR not at end. Not a ZIP file." ); } else { @@ -849,7 +852,7 @@ EOT; $callback = $this->guessCallback; if ( $callback ) { $callback( $this, $head, $tail, $file, $mime /* by reference */ ); - }; + } return $mime; } @@ -876,6 +879,14 @@ EOT; $mime = 'application/zip'; $opendocTypes = [ + # In OASIS Open Document Format v1.2, Database front end document + # has a recommended MIME type of: + # application/vnd.oasis.opendocument.base + # Despite the type registered at the IANA being 'database' which is + # supposed to be normative. + # T35515 + 'base', + 'chart-template', 'chart', 'formula-template', @@ -893,7 +904,10 @@ EOT; 'text-web', 'text' ]; - // https://lists.oasis-open.org/archives/office/200505/msg00006.html + // The list of document types is available in OASIS Open Document + // Format version 1.2 under Appendix C. It is not normative though, + // supposedly types registered at the IANA should be. + // http://docs.oasis-open.org/office/v1.2/os/OpenDocument-v1.2-os-part1.html $types = '(?:' . implode( '|', $opendocTypes ) . ')'; $opendocRegex = "/^mimetype(application\/vnd\.oasis\.opendocument\.$types)/"; diff --git a/includes/libs/mime/XmlTypeCheck.php b/includes/libs/mime/XmlTypeCheck.php index 0b523916cb..65cc54bdff 100644 --- a/includes/libs/mime/XmlTypeCheck.php +++ b/includes/libs/mime/XmlTypeCheck.php @@ -27,13 +27,13 @@ class XmlTypeCheck { /** - * Will be set to true or false to indicate whether the file is + * @var bool|null Will be set to true or false to indicate whether the file is * well-formed XML. Note that this doesn't check schema validity. */ public $wellFormed = null; /** - * Will be set to true if the optional element filter returned + * @var bool Will be set to true if the optional element filter returned * a match at some point. */ public $filterMatch = false; @@ -46,30 +46,30 @@ class XmlTypeCheck { public $filterMatchType = false; /** - * Name of the document's root element, including any namespace + * @var string Name of the document's root element, including any namespace * as an expanded URL. */ public $rootElement = ''; /** - * A stack of strings containing the data of each xml element as it's processed. Append - * data to the top string of the stack, then pop off the string and process it when the + * @var string[] A stack of strings containing the data of each xml element as it's processed. + * Append data to the top string of the stack, then pop off the string and process it when the * element is closed. */ protected $elementData = []; /** - * A stack of element names and attributes, as we process them. + * @var array A stack of element names and attributes, as we process them. */ protected $elementDataContext = []; /** - * Current depth of the data stack. + * @var int Current depth of the data stack. */ protected $stackDepth = 0; /** - * Additional parsing options + * @var array Additional parsing options */ private $parserOptions = [ 'processing_instruction_handler' => null, @@ -308,7 +308,7 @@ class XmlTypeCheck { /** * @param string $name - * @param string $attribs + * @param array $attribs */ private function elementOpen( $name, $attribs ) { $this->elementDataContext[] = [ $name, $attribs ]; diff --git a/includes/libs/objectcache/APCBagOStuff.php b/includes/libs/objectcache/APCBagOStuff.php index 5a36c651e7..0954ac8061 100644 --- a/includes/libs/objectcache/APCBagOStuff.php +++ b/includes/libs/objectcache/APCBagOStuff.php @@ -33,7 +33,7 @@ * * @ingroup Cache */ -class APCBagOStuff extends BagOStuff { +class APCBagOStuff extends MediumSpecificBagOStuff { /** @var bool Whether to trust the APC implementation to serialization */ private $nativeSerialize; @@ -45,6 +45,7 @@ class APCBagOStuff extends BagOStuff { const KEY_SUFFIX = ':4'; public function __construct( array $params = [] ) { + $params['segmentationSize'] = $params['segmentationSize'] ?? INF; parent::__construct( $params ); // The extension serializer is still buggy, unlike "php" and "igbinary" $this->nativeSerialize = ( ini_get( 'apc.serializer' ) !== 'default' ); @@ -62,7 +63,7 @@ class APCBagOStuff extends BagOStuff { return $value; } - public function set( $key, $value, $exptime = 0, $flags = 0 ) { + protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) { apc_store( $key . self::KEY_SUFFIX, $this->nativeSerialize ? $value : $this->serialize( $value ), @@ -80,7 +81,7 @@ class APCBagOStuff extends BagOStuff { ); } - public function delete( $key, $flags = 0 ) { + protected function doDelete( $key, $flags = 0 ) { apc_delete( $key . self::KEY_SUFFIX ); return true; @@ -93,12 +94,4 @@ class APCBagOStuff extends BagOStuff { public function decr( $key, $value = 1 ) { return apc_dec( $key . self::KEY_SUFFIX, $value ); } - - protected function serialize( $value ) { - return $this->isInteger( $value ) ? (int)$value : serialize( $value ); - } - - protected function unserialize( $value ) { - return $this->isInteger( $value ) ? (int)$value : unserialize( $value ); - } } diff --git a/includes/libs/objectcache/APCUBagOStuff.php b/includes/libs/objectcache/APCUBagOStuff.php index 0d9822a147..021cdf7b76 100644 --- a/includes/libs/objectcache/APCUBagOStuff.php +++ b/includes/libs/objectcache/APCUBagOStuff.php @@ -33,7 +33,7 @@ * * @ingroup Cache */ -class APCUBagOStuff extends BagOStuff { +class APCUBagOStuff extends MediumSpecificBagOStuff { /** @var bool Whether to trust the APC implementation to serialization */ private $nativeSerialize; @@ -45,6 +45,7 @@ class APCUBagOStuff extends BagOStuff { const KEY_SUFFIX = ':4'; public function __construct( array $params = [] ) { + $params['segmentationSize'] = $params['segmentationSize'] ?? INF; parent::__construct( $params ); // The extension serializer is still buggy, unlike "php" and "igbinary" $this->nativeSerialize = ( ini_get( 'apc.serializer' ) !== 'default' ); @@ -54,7 +55,7 @@ class APCUBagOStuff extends BagOStuff { $casToken = null; $blob = apcu_fetch( $key . self::KEY_SUFFIX ); - $value = $this->unserialize( $blob ); + $value = $this->nativeSerialize ? $blob : $this->unserialize( $blob ); if ( $value !== false ) { $casToken = $blob; // don't bother hashing this } @@ -62,10 +63,10 @@ class APCUBagOStuff extends BagOStuff { return $value; } - public function set( $key, $value, $exptime = 0, $flags = 0 ) { + protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) { return apcu_store( $key . self::KEY_SUFFIX, - $this->serialize( $value ), + $this->nativeSerialize ? $value : $this->serialize( $value ), $exptime ); } @@ -73,12 +74,12 @@ class APCUBagOStuff extends BagOStuff { public function add( $key, $value, $exptime = 0, $flags = 0 ) { return apcu_add( $key . self::KEY_SUFFIX, - $this->serialize( $value ), + $this->nativeSerialize ? $value : $this->serialize( $value ), $exptime ); } - public function delete( $key, $flags = 0 ) { + protected function doDelete( $key, $flags = 0 ) { apcu_delete( $key . self::KEY_SUFFIX ); return true; @@ -101,20 +102,4 @@ class APCUBagOStuff extends BagOStuff { return false; } } - - protected function serialize( $value ) { - if ( $this->nativeSerialize ) { - return $value; - } - - return $this->isInteger( $value ) ? (int)$value : serialize( $value ); - } - - protected function unserialize( $value ) { - if ( $this->nativeSerialize ) { - return $value; - } - - return $this->isInteger( $value ) ? (int)$value : unserialize( $value ); - } } diff --git a/includes/libs/objectcache/BagOStuff.php b/includes/libs/objectcache/BagOStuff.php index 321476bbf2..8c99532927 100644 --- a/includes/libs/objectcache/BagOStuff.php +++ b/includes/libs/objectcache/BagOStuff.php @@ -30,7 +30,6 @@ use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Wikimedia\ScopedCallback; -use Wikimedia\WaitConditionLoop; /** * Class representing a cache/ephemeral data store @@ -50,75 +49,52 @@ use Wikimedia\WaitConditionLoop; * For any given instance, methods like lock(), unlock(), merge(), and set() with WRITE_SYNC * should semantically operate over its entire access scope; any nodes/threads in that scope * should serialize appropriately when using them. Likewise, a call to get() with READ_LATEST - * from one node in its access scope should reflect the prior changes of any other node its access - * scope. Any get() should reflect the changes of any prior set() with WRITE_SYNC. + * from one node in its access scope should reflect the prior changes of any other node its + * access scope. Any get() should reflect the changes of any prior set() with WRITE_SYNC. + * + * Subclasses should override the default "segmentationSize" field with an appropriate value. + * The value should not be larger than what the storage backend (by default) supports. It also + * should be roughly informed by common performance bottlenecks (e.g. values over a certain size + * having poor scalability). The same goes for the "segmentedValueMaxSize" member, which limits + * the maximum size and chunk count (indirectly) of values. * * @ingroup Cache */ -abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { - /** @var array[] Lock tracking */ - protected $locks = []; - /** @var int ERR_* class constant */ - protected $lastError = self::ERR_NONE; - /** @var string */ - protected $keyspace = 'local'; +abstract class BagOStuff implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInterface { /** @var LoggerInterface */ protected $logger; + /** @var callable|null */ protected $asyncHandler; - /** @var int Seconds */ - protected $syncTimeout; + /** @var int[] Map of (ATTR_* class constant => QOS_* class constant) */ + protected $attrMap = []; /** @var bool */ - private $debugMode = false; - /** @var array */ - private $duplicateKeyLookups = []; - /** @var bool */ - private $reportDupes = false; - /** @var bool */ - private $dupeTrackScheduled = false; - - /** @var callable[] */ - protected $busyCallbacks = []; + protected $debugMode = false; /** @var float|null */ private $wallClockOverride; - /** @var int[] Map of (ATTR_* class constant => QOS_* class constant) */ - protected $attrMap = []; - - /** Bitfield constants for get()/getMulti() */ - const READ_LATEST = 1; // use latest data for replicated stores - const READ_VERIFIED = 2; // promise that caller can tell when keys are stale - /** Bitfield constants for set()/merge() */ - const WRITE_SYNC = 4; // synchronously write to all locations for replicated stores - const WRITE_CACHE_ONLY = 8; // Only change state of the in-memory cache + /** Bitfield constants for get()/getMulti(); these are only advisory */ + const READ_LATEST = 1; // if supported, avoid reading stale data due to replication + const READ_VERIFIED = 2; // promise that the caller handles detection of staleness + /** Bitfield constants for set()/merge(); these are only advisory */ + const WRITE_SYNC = 4; // if supported, block until the write is fully replicated + const WRITE_CACHE_ONLY = 8; // only change state of the in-memory cache + const WRITE_ALLOW_SEGMENTS = 16; // allow partitioning of the value if it is large + const WRITE_PRUNE_SEGMENTS = 32; // delete all the segments if the value is partitioned + const WRITE_BACKGROUND = 64; // if supported, do not block on completion until the next read /** - * $params include: + * Parameters include: * - logger: Psr\Log\LoggerInterface instance - * - keyspace: Default keyspace for $this->makeKey() * - asyncHandler: Callable to use for scheduling tasks after the web request ends. * In CLI mode, it should run the task immediately. - * - reportDupes: Whether to emit warning log messages for all keys that were - * requested more than once (requires an asyncHandler). - * - syncTimeout: How long to wait with WRITE_SYNC in seconds. * @param array $params */ public function __construct( array $params = [] ) { $this->setLogger( $params['logger'] ?? new NullLogger() ); - - if ( isset( $params['keyspace'] ) ) { - $this->keyspace = $params['keyspace']; - } - $this->asyncHandler = $params['asyncHandler'] ?? null; - - if ( !empty( $params['reportDupes'] ) && is_callable( $this->asyncHandler ) ) { - $this->reportDupes = true; - } - - $this->syncTimeout = $params['syncTimeout'] ?? 3; } /** @@ -130,10 +106,10 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { } /** - * @param bool $bool + * @param bool $enabled */ - public function setDebug( $bool ) { - $this->debugMode = $bool; + public function setDebug( $enabled ) { + $this->debugMode = $enabled; } /** @@ -177,52 +153,7 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { * @param int $flags Bitfield of BagOStuff::READ_* constants [optional] * @return mixed Returns false on failure or if the item does not exist */ - public function get( $key, $flags = 0 ) { - $this->trackDuplicateKeys( $key ); - - return $this->doGet( $key, $flags ); - } - - /** - * Track the number of times that a given key has been used. - * @param string $key - */ - private function trackDuplicateKeys( $key ) { - if ( !$this->reportDupes ) { - return; - } - - if ( !isset( $this->duplicateKeyLookups[$key] ) ) { - // Track that we have seen this key. This N-1 counting style allows - // easy filtering with array_filter() later. - $this->duplicateKeyLookups[$key] = 0; - } else { - $this->duplicateKeyLookups[$key] += 1; - - if ( $this->dupeTrackScheduled === false ) { - $this->dupeTrackScheduled = true; - // Schedule a callback that logs keys processed more than once by get(). - call_user_func( $this->asyncHandler, function () { - $dups = array_filter( $this->duplicateKeyLookups ); - foreach ( $dups as $key => $count ) { - $this->logger->warning( - 'Duplicate get(): "{key}" fetched {count} times', - // Count is N-1 of the actual lookup count - [ 'key' => $key, 'count' => $count + 1, ] - ); - } - } ); - } - } - } - - /** - * @param string $key - * @param int $flags Bitfield of BagOStuff::READ_* constants [optional] - * @param mixed|null &$casToken Token to use for check-and-set comparisons - * @return mixed Returns false on failure or if the item does not exist - */ - abstract protected function doGet( $key, $flags = 0, &$casToken = null ); + abstract public function get( $key, $flags = 0 ); /** * Set an item @@ -238,6 +169,10 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { /** * Delete an item * + * For large values written using WRITE_ALLOW_SEGMENTS, this only deletes the main + * segment list key unless WRITE_PRUNE_SEGMENTS is in the flags. While deleting the segment + * list key has the effect of functionally deleting the key, it leaves unused blobs in cache. + * * @param string $key * @return bool True if the item was deleted or not found, false on failure * @param int $flags Bitfield of BagOStuff::WRITE_* constants @@ -272,125 +207,32 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { * @return bool Success * @throws InvalidArgumentException */ - public function merge( $key, callable $callback, $exptime = 0, $attempts = 10, $flags = 0 ) { - return $this->mergeViaCas( $key, $callback, $exptime, $attempts, $flags ); - } - - /** - * @see BagOStuff::merge() - * - * @param string $key - * @param callable $callback Callback method to be executed - * @param int $exptime Either an interval in seconds or a unix timestamp for expiry - * @param int $attempts The amount of times to attempt a merge in case of failure - * @param int $flags Bitfield of BagOStuff::WRITE_* constants - * @return bool Success - */ - protected function mergeViaCas( $key, $callback, $exptime = 0, $attempts = 10, $flags = 0 ) { - do { - $casToken = null; // passed by reference - // Get the old value and CAS token from cache - $this->clearLastError(); - $currentValue = $this->doGet( $key, self::READ_LATEST, $casToken ); - if ( $this->getLastError() ) { - $this->logger->warning( - __METHOD__ . ' failed due to I/O error on get() for {key}.', - [ 'key' => $key ] - ); - - return false; // don't spam retries (retry only on races) - } - - // Derive the new value from the old value - $value = call_user_func( $callback, $this, $key, $currentValue, $exptime ); - $hadNoCurrentValue = ( $currentValue === false ); - unset( $currentValue ); // free RAM in case the value is large - - $this->clearLastError(); - if ( $value === false ) { - $success = true; // do nothing - } elseif ( $hadNoCurrentValue ) { - // Try to create the key, failing if it gets created in the meantime - $success = $this->add( $key, $value, $exptime, $flags ); - } else { - // Try to update the key, failing if it gets changed in the meantime - $success = $this->cas( $casToken, $key, $value, $exptime, $flags ); - } - if ( $this->getLastError() ) { - $this->logger->warning( - __METHOD__ . ' failed due to I/O error for {key}.', - [ 'key' => $key ] - ); - - return false; // IO error; don't spam retries - } - } while ( !$success && --$attempts ); - - return $success; - } - - /** - * Check and set an item - * - * @param mixed $casToken - * @param string $key - * @param mixed $value - * @param int $exptime Either an interval in seconds or a unix timestamp for expiry - * @param int $flags Bitfield of BagOStuff::WRITE_* constants - * @return bool Success - * @throws Exception - */ - protected function cas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) { - if ( !$this->lock( $key, 0 ) ) { - return false; // non-blocking - } - - $curCasToken = null; // passed by reference - $this->doGet( $key, self::READ_LATEST, $curCasToken ); - if ( $casToken === $curCasToken ) { - $success = $this->set( $key, $value, $exptime, $flags ); - } else { - $this->logger->info( - __METHOD__ . ' failed due to race condition for {key}.', - [ 'key' => $key ] - ); - - $success = false; // mismatched or failed - } - - $this->unlock( $key ); - - return $success; - } + abstract public function merge( + $key, + callable $callback, + $exptime = 0, + $attempts = 10, + $flags = 0 + ); /** * Change the expiration on a key if it exists * * If an expiry in the past is given then the key will immediately be expired * + * For large values written using WRITE_ALLOW_SEGMENTS, this only changes the TTL of the + * main segment list key. While lowering the TTL of the segment list key has the effect of + * functionally lowering the TTL of the key, it might leave unused blobs in cache for longer. + * Raising the TTL of such keys is not effective, since the expiration of a single segment + * key effectively expires the entire value. + * * @param string $key - * @param int $expiry TTL or UNIX timestamp + * @param int $exptime TTL or UNIX timestamp * @param int $flags Bitfield of BagOStuff::WRITE_* constants (since 1.33) * @return bool Success Returns false on failure or if the item does not exist * @since 1.28 */ - public function changeTTL( $key, $expiry = 0, $flags = 0 ) { - $found = false; - - $ok = $this->merge( - $key, - function ( $cache, $ttl, $currentValue ) use ( &$found ) { - $found = ( $currentValue !== false ); - - return $currentValue; // nothing is written if this is false - }, - $expiry, - 1, // 1 attempt - $flags - ); - - return ( $ok && $found ); - } + abstract public function changeTTL( $key, $exptime = 0, $flags = 0 ); /** * Acquire an advisory lock on a key string @@ -403,51 +245,7 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { * @param string $rclass Allow reentry if set and the current lock used this value * @return bool Success */ - public function lock( $key, $timeout = 6, $expiry = 6, $rclass = '' ) { - // Avoid deadlocks and allow lock reentry if specified - if ( isset( $this->locks[$key] ) ) { - if ( $rclass != '' && $this->locks[$key]['class'] === $rclass ) { - ++$this->locks[$key]['depth']; - return true; - } else { - return false; - } - } - - $fname = __METHOD__; - $expiry = min( $expiry ?: INF, self::TTL_DAY ); - $loop = new WaitConditionLoop( - function () use ( $key, $expiry, $fname ) { - $this->clearLastError(); - if ( $this->add( "{$key}:lock", 1, $expiry ) ) { - return WaitConditionLoop::CONDITION_REACHED; // locked! - } elseif ( $this->getLastError() ) { - $this->logger->warning( - $fname . ' failed due to I/O error for {key}.', - [ 'key' => $key ] - ); - - return WaitConditionLoop::CONDITION_ABORTED; // network partition? - } - - return WaitConditionLoop::CONDITION_CONTINUE; - }, - $timeout - ); - - $code = $loop->invoke(); - $locked = ( $code === $loop::CONDITION_REACHED ); - if ( $locked ) { - $this->locks[$key] = [ 'class' => $rclass, 'depth' => 1 ]; - } elseif ( $code === $loop::CONDITION_TIMED_OUT ) { - $this->logger->warning( - "$fname failed due to timeout for {key}.", - [ 'key' => $key, 'timeout' => $timeout ] - ); - } - - return $locked; - } + abstract public function lock( $key, $timeout = 6, $expiry = 6, $rclass = '' ); /** * Release an advisory lock on a key string @@ -455,23 +253,7 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { * @param string $key * @return bool Success */ - public function unlock( $key ) { - if ( isset( $this->locks[$key] ) && --$this->locks[$key]['depth'] <= 0 ) { - unset( $this->locks[$key] ); - - $ok = $this->delete( "{$key}:lock" ); - if ( !$ok ) { - $this->logger->warning( - __METHOD__ . ' failed to release lock for {key}.', - [ 'key' => $key ] - ); - } - - return $ok; - } - - return true; - } + abstract public function unlock( $key ); /** * Get a lightweight exclusive self-unlocking lock @@ -514,70 +296,69 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { /** * Delete all objects expiring before a certain date. - * @param string $date The reference date in MW format - * @param callable|bool $progressCallback Optional, a function which will be called + * @param string|int $timestamp The reference date in MW or TS_UNIX format + * @param callable|null $progress Optional, a function which will be called * regularly during long-running operations with the percentage progress - * as the first parameter. + * as the first parameter. [optional] + * @param int $limit Maximum number of keys to delete [default: INF] * - * @return bool Success, false if unimplemented + * @return bool Success; false if unimplemented */ - public function deleteObjectsExpiringBefore( $date, $progressCallback = false ) { - // stub - return false; - } + abstract public function deleteObjectsExpiringBefore( + $timestamp, + callable $progress = null, + $limit = INF + ); /** * Get an associative array containing the item for each of the keys that have items. * @param string[] $keys List of keys * @param int $flags Bitfield; supports READ_LATEST [optional] - * @return array - */ - public function getMulti( array $keys, $flags = 0 ) { - $res = []; - foreach ( $keys as $key ) { - $val = $this->get( $key, $flags ); - if ( $val !== false ) { - $res[$key] = $val; - } - } - - return $res; - } + * @return mixed[] Map of (key => value) for existing keys + */ + abstract public function getMulti( array $keys, $flags = 0 ); /** * Batch insertion/replace + * + * This does not support WRITE_ALLOW_SEGMENTS to avoid excessive read I/O + * + * WRITE_BACKGROUND can be used for bulk insertion where the response is not vital + * * @param mixed[] $data Map of (key => value) * @param int $exptime Either an interval in seconds or a unix timestamp for expiry * @param int $flags Bitfield of BagOStuff::WRITE_* constants (since 1.33) * @return bool Success * @since 1.24 */ - public function setMulti( array $data, $exptime = 0, $flags = 0 ) { - $res = true; - foreach ( $data as $key => $value ) { - if ( !$this->set( $key, $value, $exptime, $flags ) ) { - $res = false; - } - } - - return $res; - } + abstract public function setMulti( array $data, $exptime = 0, $flags = 0 ); /** * Batch deletion + * + * This does not support WRITE_ALLOW_SEGMENTS to avoid excessive read I/O + * + * WRITE_BACKGROUND can be used for bulk deletion where the response is not vital + * * @param string[] $keys List of keys * @param int $flags Bitfield of BagOStuff::WRITE_* constants * @return bool Success * @since 1.33 */ - public function deleteMulti( array $keys, $flags = 0 ) { - $res = true; - foreach ( $keys as $key ) { - $res = $this->delete( $key, $flags ) && $res; - } + abstract public function deleteMulti( array $keys, $flags = 0 ); - return $res; - } + /** + * Change the expiration of multiple keys that exist + * + * @see BagOStuff::changeTTL() + * + * @param string[] $keys List of keys + * @param int $exptime TTL or UNIX timestamp + * @param int $flags Bitfield of BagOStuff::WRITE_* constants (since 1.33) + * @return bool Success + * @since 1.34 + */ + abstract public function changeTTLMulti( array $keys, $exptime, $flags = 0 ); /** * Increase stored value of $key by $value while preserving its TTL @@ -593,9 +374,7 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { * @param int $value Value to subtract from $key (default: 1) [optional] * @return int|bool New value or false on failure */ - public function decr( $key, $value = 1 ) { - return $this->incr( $key, - $value ); - } + abstract public function decr( $key, $value = 1 ); /** * Increase stored value of $key by $value while preserving its TTL @@ -609,46 +388,20 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { * @return int|bool New value or false on failure * @since 1.24 */ - public function incrWithInit( $key, $ttl, $value = 1, $init = 1 ) { - $this->clearLastError(); - $newValue = $this->incr( $key, $value ); - if ( $newValue === false && !$this->getLastError() ) { - // No key set; initialize - $newValue = $this->add( $key, (int)$init, $ttl ) ? $init : false; - if ( $newValue === false && !$this->getLastError() ) { - // Raced out initializing; increment - $newValue = $this->incr( $key, $value ); - } - } - - return $newValue; - } + abstract public function incrWithInit( $key, $ttl, $value = 1, $init = 1 ); /** * Get the "last error" registered; clearLastError() should be called manually * @return int ERR_* constant for the "last error" registry * @since 1.23 */ - public function getLastError() { - return $this->lastError; - } + abstract public function getLastError(); /** * Clear the "last error" registry * @since 1.23 */ - public function clearLastError() { - $this->lastError = self::ERR_NONE; - } - - /** - * Set the "last error" registry - * @param int $err ERR_* constant - * @since 1.23 - */ - protected function setLastError( $err ) { - $this->lastError = $err; - } + abstract public function clearLastError(); /** * Let a callback be run to avoid wasting time on special blocking calls @@ -670,70 +423,7 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { * @param callable $workCallback * @since 1.28 */ - public function addBusyCallback( callable $workCallback ) { - $this->busyCallbacks[] = $workCallback; - } - - /** - * @param string $text - */ - protected function debug( $text ) { - if ( $this->debugMode ) { - $this->logger->debug( "{class} debug: $text", [ - 'class' => static::class, - ] ); - } - } - - /** - * @param int $exptime - * @return bool - */ - protected function expiryIsRelative( $exptime ) { - return ( $exptime != 0 && $exptime < ( 10 * self::TTL_YEAR ) ); - } - - /** - * Convert an optionally relative time to an absolute time - * @param int $exptime - * @return int - */ - protected function convertToExpiry( $exptime ) { - if ( $this->expiryIsRelative( $exptime ) ) { - return (int)$this->getCurrentTime() + $exptime; - } else { - return $exptime; - } - } - - /** - * Convert an optionally absolute expiry time to a relative time. If an - * absolute time is specified which is in the past, use a short expiry time. - * - * @param int $exptime - * @return int - */ - protected function convertToRelative( $exptime ) { - if ( $exptime >= ( 10 * self::TTL_YEAR ) ) { - $exptime -= (int)$this->getCurrentTime(); - if ( $exptime <= 0 ) { - $exptime = 1; - } - return $exptime; - } else { - return $exptime; - } - } - - /** - * Check if a value is an integer - * - * @param mixed $value - * @return bool - */ - protected function isInteger( $value ) { - return ( is_int( $value ) || ctype_digit( $value ) ); - } + abstract public function addBusyCallback( callable $workCallback ); /** * Construct a cache key. @@ -743,37 +433,27 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { * @param array $args * @return string Colon-delimited list of $keyspace followed by escaped components of $args */ - public function makeKeyInternal( $keyspace, $args ) { - $key = $keyspace; - foreach ( $args as $arg ) { - $key .= ':' . str_replace( ':', '%3A', $arg ); - } - return strtr( $key, ' ', '_' ); - } + abstract public function makeKeyInternal( $keyspace, $args ); /** * Make a global cache key. * * @since 1.27 * @param string $class Key class - * @param string|null $component [optional] Key component (starting with a key collection name) - * @return string Colon-delimited list of $keyspace followed by escaped components of $args + * @param string ...$components Key components (starting with a key collection name) + * @return string Colon-delimited list of $keyspace followed by escaped components */ - public function makeGlobalKey( $class, $component = null ) { - return $this->makeKeyInternal( 'global', func_get_args() ); - } + abstract public function makeGlobalKey( $class, ...$components ); /** * Make a cache key, scoped to this instance's keyspace. * * @since 1.27 * @param string $class Key class - * @param string|null $component [optional] Key component (starting with a key collection name) - * @return string Colon-delimited list of $keyspace followed by escaped components of $args + * @param string ...$components Key components (starting with a key collection name) + * @return string Colon-delimited list of $keyspace followed by escaped components */ - public function makeKey( $class, $component = null ) { - return $this->makeKeyInternal( $this->keyspace, func_get_args() ); - } + abstract public function makeKey( $class, ...$components ); /** * @param int $flag ATTR_* class constant @@ -784,13 +464,29 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { return $this->attrMap[$flag] ?? self::QOS_UNKNOWN; } + /** + * @return int|float The chunk size, in bytes, of segmented objects (INF for no limit) + * @since 1.34 + */ + public function getSegmentationSize() { + return INF; + } + + /** + * @return int|float Maximum total segmented object size in bytes (INF for no limit) + * @since 1.34 + */ + public function getSegmentedValueMaxSize() { + return INF; + } + /** * Merge the flag maps of one or more BagOStuff objects into a "lowest common denominator" map * * @param BagOStuff[] $bags * @return int[] Resulting flag map (class ATTR_* constant => class QOS_* constant) */ - protected function mergeFlagMaps( array $bags ) { + final protected function mergeFlagMaps( array $bags ) { $map = []; foreach ( $bags as $bag ) { foreach ( $bag->attrMap as $attr => $rank ) { diff --git a/includes/libs/objectcache/CachedBagOStuff.php b/includes/libs/objectcache/CachedBagOStuff.php index 8892f73899..0ab26c9520 100644 --- a/includes/libs/objectcache/CachedBagOStuff.php +++ b/includes/libs/objectcache/CachedBagOStuff.php @@ -32,76 +32,77 @@ * up going to the HashBagOStuff used for the in-memory cache). * * @ingroup Cache - * @TODO: Make this class use composition instead of calling super */ -class CachedBagOStuff extends HashBagOStuff { +class CachedBagOStuff extends BagOStuff { /** @var BagOStuff */ protected $backend; + /** @var HashBagOStuff */ + protected $procCache; /** * @param BagOStuff $backend Permanent backend to use * @param array $params Parameters for HashBagOStuff */ - function __construct( BagOStuff $backend, $params = [] ) { - unset( $params['reportDupes'] ); // useless here - + public function __construct( BagOStuff $backend, $params = [] ) { parent::__construct( $params ); $this->backend = $backend; + $this->procCache = new HashBagOStuff( $params ); $this->attrMap = $backend->attrMap; } - public function get( $key, $flags = 0 ) { - $ret = parent::get( $key, $flags ); - if ( $ret === false && !$this->hasKey( $key ) ) { - $ret = $this->backend->get( $key, $flags ); - $this->set( $key, $ret, 0, self::WRITE_CACHE_ONLY ); - } - return $ret; + public function setDebug( $enabled ) { + parent::setDebug( $enabled ); + $this->backend->setDebug( $enabled ); } - public function set( $key, $value, $exptime = 0, $flags = 0 ) { - parent::set( $key, $value, $exptime, $flags ); - if ( !( $flags & self::WRITE_CACHE_ONLY ) ) { - $this->backend->set( $key, $value, $exptime, $flags & ~self::WRITE_CACHE_ONLY ); + public function get( $key, $flags = 0 ) { + $value = $this->procCache->get( $key, $flags ); + if ( $value === false && !$this->procCache->hasKey( $key ) ) { + $value = $this->backend->get( $key, $flags ); + $this->set( $key, $value, self::TTL_INDEFINITE, self::WRITE_CACHE_ONLY ); } - return true; + + return $value; } - public function delete( $key, $flags = 0 ) { - parent::delete( $key, $flags ); - if ( !( $flags & self::WRITE_CACHE_ONLY ) ) { - $this->backend->delete( $key ); + public function getMulti( array $keys, $flags = 0 ) { + $valuesByKeyCached = []; + + $keysMissing = []; + foreach ( $keys as $key ) { + $value = $this->procCache->get( $key, $flags ); + if ( $value === false && !$this->procCache->hasKey( $key ) ) { + $keysMissing[] = $key; + } else { + $valuesByKeyCached[$key] = $value; + } } - return true; - } + $valuesByKeyFetched = $this->backend->getMulti( $keys, $flags ); + $this->setMulti( $valuesByKeyFetched, self::TTL_INDEFINITE, self::WRITE_CACHE_ONLY ); - public function setDebug( $bool ) { - parent::setDebug( $bool ); - $this->backend->setDebug( $bool ); + return $valuesByKeyCached + $valuesByKeyFetched; } - public function deleteObjectsExpiringBefore( $date, $progressCallback = false ) { - parent::deleteObjectsExpiringBefore( $date, $progressCallback ); - return $this->backend->deleteObjectsExpiringBefore( $date, $progressCallback ); - } + public function set( $key, $value, $exptime = 0, $flags = 0 ) { + $this->procCache->set( $key, $value, $exptime, $flags ); + if ( ( $flags & self::WRITE_CACHE_ONLY ) != self::WRITE_CACHE_ONLY ) { + $this->backend->set( $key, $value, $exptime, $flags ); + } - public function makeKeyInternal( $keyspace, $args ) { - return $this->backend->makeKeyInternal( ...func_get_args() ); + return true; } - public function makeKey( $class, $component = null ) { - return $this->backend->makeKey( ...func_get_args() ); - } + public function delete( $key, $flags = 0 ) { + $this->procCache->delete( $key, $flags ); + if ( ( $flags & self::WRITE_CACHE_ONLY ) != self::WRITE_CACHE_ONLY ) { + $this->backend->delete( $key, $flags ); + } - public function makeGlobalKey( $class, $component = null ) { - return $this->backend->makeGlobalKey( ...func_get_args() ); + return true; } - // These just call the backend (tested elsewhere) - // @codeCoverageIgnoreStart - public function add( $key, $value, $exptime = 0, $flags = 0 ) { if ( $this->get( $key ) === false ) { return $this->set( $key, $value, $exptime, $flags ); @@ -110,11 +111,19 @@ class CachedBagOStuff extends HashBagOStuff { return false; // key already set } - public function incr( $key, $value = 1 ) { - $n = $this->backend->incr( $key, $value ); - parent::delete( $key ); + // These just call the backend (tested elsewhere) + // @codeCoverageIgnoreStart + + public function merge( $key, callable $callback, $exptime = 0, $attempts = 10, $flags = 0 ) { + $this->procCache->delete( $key ); - return $n; + return $this->backend->merge( $key, $callback, $exptime, $attempts, $flags ); + } + + public function changeTTL( $key, $exptime = 0, $flags = 0 ) { + $this->procCache->delete( $key ); + + return $this->backend->changeTTL( $key, $exptime, $flags ); } public function lock( $key, $timeout = 6, $expiry = 6, $rclass = '' ) { @@ -125,6 +134,28 @@ class CachedBagOStuff extends HashBagOStuff { return $this->backend->unlock( $key ); } + public function deleteObjectsExpiringBefore( + $timestamp, + callable $progress = null, + $limit = INF + ) { + $this->procCache->deleteObjectsExpiringBefore( $timestamp, $progress, $limit ); + + return $this->backend->deleteObjectsExpiringBefore( $timestamp, $progress, $limit ); + } + + public function makeKeyInternal( $keyspace, $args ) { + return $this->backend->makeKeyInternal( $keyspace, $args ); + } + + public function makeKey( $class, ...$components ) { + return $this->backend->makeKey( $class, ...$components ); + } + + public function makeGlobalKey( $class, ...$components ) { + return $this->backend->makeGlobalKey( $class, ...$components ); + } + public function getLastError() { return $this->backend->getLastError(); } @@ -133,5 +164,60 @@ class CachedBagOStuff extends HashBagOStuff { return $this->backend->clearLastError(); } + public function setMulti( array $data, $exptime = 0, $flags = 0 ) { + $this->procCache->setMulti( $data, $exptime, $flags ); + if ( ( $flags & self::WRITE_CACHE_ONLY ) != self::WRITE_CACHE_ONLY ) { + return $this->backend->setMulti( $data, $exptime, $flags ); + } + + return true; + } + + public function deleteMulti( array $keys, $flags = 0 ) { + $this->procCache->deleteMulti( $keys, $flags ); + if ( ( $flags & self::WRITE_CACHE_ONLY ) != self::WRITE_CACHE_ONLY ) { + return $this->backend->deleteMulti( $keys, $flags ); + } + + return true; + } + + public function changeTTLMulti( array $keys, $exptime, $flags = 0 ) { + $this->procCache->changeTTLMulti( $keys, $exptime, $flags ); + if ( ( $flags & self::WRITE_CACHE_ONLY ) != self::WRITE_CACHE_ONLY ) { + return $this->backend->changeTTLMulti( $keys, $exptime, $flags ); + } + + return true; + } + + public function incr( $key, $value = 1 ) { + $this->procCache->delete( $key ); + + return $this->backend->incr( $key, $value ); + } + + public function decr( $key, $value = 1 ) { + $this->procCache->delete( $key ); + + return $this->backend->decr( $key, $value ); + } + + public function incrWithInit( $key, $ttl, $value = 1, $init = 1 ) { + $this->procCache->delete( $key ); + + return $this->backend->incrWithInit( $key, $ttl, $value, $init ); + } + + public function addBusyCallback( callable $workCallback ) { + $this->backend->addBusyCallback( $workCallback ); + } + + public function setMockTime( &$time ) { + parent::setMockTime( $time ); + $this->procCache->setMockTime( $time ); + $this->backend->setMockTime( $time ); + } + // @codeCoverageIgnoreEnd } diff --git a/includes/libs/objectcache/EmptyBagOStuff.php b/includes/libs/objectcache/EmptyBagOStuff.php index ffe3a4c53e..dab8ba1d35 100644 --- a/includes/libs/objectcache/EmptyBagOStuff.php +++ b/includes/libs/objectcache/EmptyBagOStuff.php @@ -26,22 +26,22 @@ * * @ingroup Cache */ -class EmptyBagOStuff extends BagOStuff { +class EmptyBagOStuff extends MediumSpecificBagOStuff { protected function doGet( $key, $flags = 0, &$casToken = null ) { $casToken = null; return false; } - public function add( $key, $value, $exp = 0, $flags = 0 ) { + protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) { return true; } - public function set( $key, $value, $exp = 0, $flags = 0 ) { + protected function doDelete( $key, $flags = 0 ) { return true; } - public function delete( $key, $flags = 0 ) { + public function add( $key, $value, $exptime = 0, $flags = 0 ) { return true; } @@ -49,6 +49,10 @@ class EmptyBagOStuff extends BagOStuff { return false; } + public function incrWithInit( $key, $ttl, $value = 1, $init = 1 ) { + return false; // faster + } + public function merge( $key, callable $callback, $exptime = 0, $attempts = 10, $flags = 0 ) { return true; // faster } diff --git a/includes/libs/objectcache/HashBagOStuff.php b/includes/libs/objectcache/HashBagOStuff.php index d24f40854e..83c8004c26 100644 --- a/includes/libs/objectcache/HashBagOStuff.php +++ b/includes/libs/objectcache/HashBagOStuff.php @@ -28,7 +28,7 @@ * * @ingroup Cache */ -class HashBagOStuff extends BagOStuff { +class HashBagOStuff extends MediumSpecificBagOStuff { /** @var mixed[] */ protected $bag = []; /** @var int Max entries allowed */ @@ -49,6 +49,7 @@ class HashBagOStuff extends BagOStuff { * - maxKeys : only allow this many keys (using oldest-first eviction) */ function __construct( $params = [] ) { + $params['segmentationSize'] = $params['segmentationSize'] ?? INF; parent::__construct( $params ); $this->token = microtime( true ) . ':' . mt_rand(); @@ -75,7 +76,7 @@ class HashBagOStuff extends BagOStuff { return $this->bag[$key][self::KEY_VAL]; } - public function set( $key, $value, $exptime = 0, $flags = 0 ) { + protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) { // Refresh key position for maxCacheKeys eviction unset( $this->bag[$key] ); $this->bag[$key] = [ @@ -94,14 +95,14 @@ class HashBagOStuff extends BagOStuff { } public function add( $key, $value, $exptime = 0, $flags = 0 ) { - if ( $this->get( $key ) === false ) { - return $this->set( $key, $value, $exptime, $flags ); + if ( $this->hasKey( $key ) && !$this->expire( $key ) ) { + return false; // key already set } - return false; // key already set + return $this->doSet( $key, $value, $exptime, $flags ); } - public function delete( $key, $flags = 0 ) { + protected function doDelete( $key, $flags = 0 ) { unset( $this->bag[$key] ); return true; @@ -136,7 +137,7 @@ class HashBagOStuff extends BagOStuff { return false; } - $this->delete( $key ); + $this->doDelete( $key ); return true; } @@ -148,7 +149,7 @@ class HashBagOStuff extends BagOStuff { * @return bool * @since 1.27 */ - protected function hasKey( $key ) { + public function hasKey( $key ) { return isset( $this->bag[$key] ); } } diff --git a/includes/libs/objectcache/IExpiringStore.php b/includes/libs/objectcache/IExpiringStore.php index 61a4c618cf..1566c07925 100644 --- a/includes/libs/objectcache/IExpiringStore.php +++ b/includes/libs/objectcache/IExpiringStore.php @@ -17,7 +17,6 @@ * * @file * @ingroup Cache - * @author 2015 Timo Tijhof */ /** diff --git a/includes/libs/objectcache/IStoreKeyEncoder.php b/includes/libs/objectcache/IStoreKeyEncoder.php new file mode 100644 index 0000000000..59e1c0504b --- /dev/null +++ b/includes/libs/objectcache/IStoreKeyEncoder.php @@ -0,0 +1,27 @@ +makeKey() + * - reportDupes: Whether to emit warning log messages for all keys that were + * requested more than once (requires an asyncHandler). + * - syncTimeout: How long to wait with WRITE_SYNC in seconds. + * - segmentationSize: The chunk size, in bytes, of segmented values. The value should + * not exceed the maximum size of values in the storage backend, as configured by + * the site administrator. + * - segmentedValueMaxSize: The maximum total size, in bytes, of segmented values. + * This should be configured to a reasonable size give the site traffic and the + * amount of I/O between application and cache servers that the network can handle. + * @param array $params + */ + public function __construct( array $params = [] ) { + parent::__construct( $params ); + + if ( isset( $params['keyspace'] ) ) { + $this->keyspace = $params['keyspace']; + } + + if ( !empty( $params['reportDupes'] ) && is_callable( $this->asyncHandler ) ) { + $this->reportDupes = true; + } + + $this->syncTimeout = $params['syncTimeout'] ?? 3; + $this->segmentationSize = $params['segmentationSize'] ?? 8388608; // 8MiB + $this->segmentedValueMaxSize = $params['segmentedValueMaxSize'] ?? 67108864; // 64MiB + } + + /** + * Get an item with the given key + * + * If the key includes a deterministic input hash (e.g. the key can only have + * the correct value) or complete staleness checks are handled by the caller + * (e.g. nothing relies on the TTL), then the READ_VERIFIED flag should be set. + * This lets tiered backends know they can safely upgrade a cached value to + * higher tiers using standard TTLs. + * + * @param string $key + * @param int $flags Bitfield of BagOStuff::READ_* constants [optional] + * @return mixed Returns false on failure or if the item does not exist + */ + public function get( $key, $flags = 0 ) { + $this->trackDuplicateKeys( $key ); + + return $this->resolveSegments( $key, $this->doGet( $key, $flags ) ); + } + + /** + * Track the number of times that a given key has been used. + * @param string $key + */ + private function trackDuplicateKeys( $key ) { + if ( !$this->reportDupes ) { + return; + } + + if ( !isset( $this->duplicateKeyLookups[$key] ) ) { + // Track that we have seen this key. This N-1 counting style allows + // easy filtering with array_filter() later. + $this->duplicateKeyLookups[$key] = 0; + } else { + $this->duplicateKeyLookups[$key] += 1; + + if ( $this->dupeTrackScheduled === false ) { + $this->dupeTrackScheduled = true; + // Schedule a callback that logs keys processed more than once by get(). + call_user_func( $this->asyncHandler, function () { + $dups = array_filter( $this->duplicateKeyLookups ); + foreach ( $dups as $key => $count ) { + $this->logger->warning( + 'Duplicate get(): "{key}" fetched {count} times', + // Count is N-1 of the actual lookup count + [ 'key' => $key, 'count' => $count + 1, ] + ); + } + } ); + } + } + } + + /** + * @param string $key + * @param int $flags Bitfield of BagOStuff::READ_* constants [optional] + * @param mixed|null &$casToken Token to use for check-and-set comparisons + * @return mixed Returns false on failure or if the item does not exist + */ + abstract protected function doGet( $key, $flags = 0, &$casToken = null ); + + /** + * Set an item + * + * @param string $key + * @param mixed $value + * @param int $exptime Either an interval in seconds or a unix timestamp for expiry + * @param int $flags Bitfield of BagOStuff::WRITE_* constants + * @return bool Success + */ + public function set( $key, $value, $exptime = 0, $flags = 0 ) { + if ( + is_int( $value ) || // avoid breaking incr()/decr() + ( $flags & self::WRITE_ALLOW_SEGMENTS ) != self::WRITE_ALLOW_SEGMENTS || + is_infinite( $this->segmentationSize ) + ) { + return $this->doSet( $key, $value, $exptime, $flags ); + } + + $serialized = $this->serialize( $value ); + $segmentSize = $this->getSegmentationSize(); + $maxTotalSize = $this->getSegmentedValueMaxSize(); + + $size = strlen( $serialized ); + if ( $size <= $segmentSize ) { + // Since the work of serializing it was already done, just use it inline + return $this->doSet( + $key, + SerializedValueContainer::newUnified( $serialized ), + $exptime, + $flags + ); + } elseif ( $size > $maxTotalSize ) { + $this->setLastError( "Key $key exceeded $maxTotalSize bytes." ); + + return false; + } + + $chunksByKey = []; + $segmentHashes = []; + $count = intdiv( $size, $segmentSize ) + ( ( $size % $segmentSize ) ? 1 : 0 ); + for ( $i = 0; $i < $count; ++$i ) { + $segment = substr( $serialized, $i * $segmentSize, $segmentSize ); + $hash = sha1( $segment ); + $chunkKey = $this->makeGlobalKey( self::SEGMENT_COMPONENT, $key, $hash ); + $chunksByKey[$chunkKey] = $segment; + $segmentHashes[] = $hash; + } + + $flags &= ~self::WRITE_ALLOW_SEGMENTS; // sanity + $ok = $this->setMulti( $chunksByKey, $exptime, $flags ); + if ( $ok ) { + // Only when all segments are stored should the main key be changed + $ok = $this->doSet( + $key, + SerializedValueContainer::newSegmented( $segmentHashes ), + $exptime, + $flags + ); + } + + return $ok; + } + + /** + * Set an item + * + * @param string $key + * @param mixed $value + * @param int $exptime Either an interval in seconds or a unix timestamp for expiry + * @param int $flags Bitfield of BagOStuff::WRITE_* constants + * @return bool Success + */ + abstract protected function doSet( $key, $value, $exptime = 0, $flags = 0 ); + + /** + * Delete an item + * + * For large values written using WRITE_ALLOW_SEGMENTS, this only deletes the main + * segment list key unless WRITE_PRUNE_SEGMENTS is in the flags. While deleting the segment + * list key has the effect of functionally deleting the key, it leaves unused blobs in cache. + * + * @param string $key + * @param int $flags Bitfield of BagOStuff::WRITE_* constants + * @return bool True if the item was deleted or not found, false on failure + */ + public function delete( $key, $flags = 0 ) { + if ( ( $flags & self::WRITE_PRUNE_SEGMENTS ) != self::WRITE_PRUNE_SEGMENTS ) { + return $this->doDelete( $key, $flags ); + } + + $mainValue = $this->doGet( $key, self::READ_LATEST ); + if ( !$this->doDelete( $key, $flags ) ) { + return false; + } + + if ( !SerializedValueContainer::isSegmented( $mainValue ) ) { + return true; // no segments to delete + } + + $orderedKeys = array_map( + function ( $segmentHash ) use ( $key ) { + return $this->makeGlobalKey( self::SEGMENT_COMPONENT, $key, $segmentHash ); + }, + $mainValue->{SerializedValueContainer::SEGMENTED_HASHES} + ); + + return $this->deleteMulti( $orderedKeys, $flags ); + } + + /** + * Delete an item + * + * @param string $key + * @param int $flags Bitfield of BagOStuff::WRITE_* constants + * @return bool True if the item was deleted or not found, false on failure + */ + abstract protected function doDelete( $key, $flags = 0 ); + + /** + * Merge changes into the existing cache value (possibly creating a new one) + * + * The callback function returns the new value given the current value + * (which will be false if not present), and takes the arguments: + * (this BagOStuff, cache key, current value, TTL). + * The TTL parameter is reference set to $exptime. It can be overriden in the callback. + * Nothing is stored nor deleted if the callback returns false. + * + * @param string $key + * @param callable $callback Callback method to be executed + * @param int $exptime Either an interval in seconds or a unix timestamp for expiry + * @param int $attempts The amount of times to attempt a merge in case of failure + * @param int $flags Bitfield of BagOStuff::WRITE_* constants + * @return bool Success + * @throws InvalidArgumentException + */ + public function merge( $key, callable $callback, $exptime = 0, $attempts = 10, $flags = 0 ) { + return $this->mergeViaCas( $key, $callback, $exptime, $attempts, $flags ); + } + + /** + * @param string $key + * @param callable $callback Callback method to be executed + * @param int $exptime Either an interval in seconds or a unix timestamp for expiry + * @param int $attempts The amount of times to attempt a merge in case of failure + * @param int $flags Bitfield of BagOStuff::WRITE_* constants + * @return bool Success + * @see BagOStuff::merge() + * + */ + final protected function mergeViaCas( $key, callable $callback, $exptime, $attempts, $flags ) { + do { + $casToken = null; // passed by reference + // Get the old value and CAS token from cache + $this->clearLastError(); + $currentValue = $this->resolveSegments( + $key, + $this->doGet( $key, self::READ_LATEST, $casToken ) + ); + if ( $this->getLastError() ) { + $this->logger->warning( + __METHOD__ . ' failed due to I/O error on get() for {key}.', + [ 'key' => $key ] + ); + + return false; // don't spam retries (retry only on races) + } + + // Derive the new value from the old value + $value = call_user_func( $callback, $this, $key, $currentValue, $exptime ); + $hadNoCurrentValue = ( $currentValue === false ); + unset( $currentValue ); // free RAM in case the value is large + + $this->clearLastError(); + if ( $value === false ) { + $success = true; // do nothing + } elseif ( $hadNoCurrentValue ) { + // Try to create the key, failing if it gets created in the meantime + $success = $this->add( $key, $value, $exptime, $flags ); + } else { + // Try to update the key, failing if it gets changed in the meantime + $success = $this->cas( $casToken, $key, $value, $exptime, $flags ); + } + if ( $this->getLastError() ) { + $this->logger->warning( + __METHOD__ . ' failed due to I/O error for {key}.', + [ 'key' => $key ] + ); + + return false; // IO error; don't spam retries + } + + } while ( !$success && --$attempts ); + + return $success; + } + + /** + * Check and set an item + * + * @param mixed $casToken + * @param string $key + * @param mixed $value + * @param int $exptime Either an interval in seconds or a unix timestamp for expiry + * @param int $flags Bitfield of BagOStuff::WRITE_* constants + * @return bool Success + */ + protected function cas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) { + if ( !$this->lock( $key, 0 ) ) { + return false; // non-blocking + } + + $curCasToken = null; // passed by reference + $this->doGet( $key, self::READ_LATEST, $curCasToken ); + if ( $casToken === $curCasToken ) { + $success = $this->set( $key, $value, $exptime, $flags ); + } else { + $this->logger->info( + __METHOD__ . ' failed due to race condition for {key}.', + [ 'key' => $key ] + ); + + $success = false; // mismatched or failed + } + + $this->unlock( $key ); + + return $success; + } + + /** + * Change the expiration on a key if it exists + * + * If an expiry in the past is given then the key will immediately be expired + * + * For large values written using WRITE_ALLOW_SEGMENTS, this only changes the TTL of the + * main segment list key. While lowering the TTL of the segment list key has the effect of + * functionally lowering the TTL of the key, it might leave unused blobs in cache for longer. + * Raising the TTL of such keys is not effective, since the expiration of a single segment + * key effectively expires the entire value. + * + * @param string $key + * @param int $exptime TTL or UNIX timestamp + * @param int $flags Bitfield of BagOStuff::WRITE_* constants (since 1.33) + * @return bool Success Returns false on failure or if the item does not exist + * @since 1.28 + */ + public function changeTTL( $key, $exptime = 0, $flags = 0 ) { + return $this->doChangeTTL( $key, $exptime, $flags ); + } + + /** + * @param string $key + * @param int $exptime + * @param int $flags + * @return bool + */ + protected function doChangeTTL( $key, $exptime, $flags ) { + $expiry = $this->convertToExpiry( $exptime ); + $delete = ( $expiry != 0 && $expiry < $this->getCurrentTime() ); + + if ( !$this->lock( $key, 0 ) ) { + return false; + } + // Use doGet() to avoid having to trigger resolveSegments() + $blob = $this->doGet( $key, self::READ_LATEST ); + if ( $blob ) { + if ( $delete ) { + $ok = $this->doDelete( $key, $flags ); + } else { + $ok = $this->doSet( $key, $blob, $exptime, $flags ); + } + } else { + $ok = false; + } + + $this->unlock( $key ); + + return $ok; + } + + /** + * Acquire an advisory lock on a key string + * + * Note that if reentry is enabled, duplicate calls ignore $expiry + * + * @param string $key + * @param int $timeout Lock wait timeout; 0 for non-blocking [optional] + * @param int $expiry Lock expiry [optional]; 1 day maximum + * @param string $rclass Allow reentry if set and the current lock used this value + * @return bool Success + */ + public function lock( $key, $timeout = 6, $expiry = 6, $rclass = '' ) { + // Avoid deadlocks and allow lock reentry if specified + if ( isset( $this->locks[$key] ) ) { + if ( $rclass != '' && $this->locks[$key]['class'] === $rclass ) { + ++$this->locks[$key]['depth']; + return true; + } else { + return false; + } + } + + $fname = __METHOD__; + $expiry = min( $expiry ?: INF, self::TTL_DAY ); + $loop = new WaitConditionLoop( + function () use ( $key, $expiry, $fname ) { + $this->clearLastError(); + if ( $this->add( "{$key}:lock", 1, $expiry ) ) { + return WaitConditionLoop::CONDITION_REACHED; // locked! + } elseif ( $this->getLastError() ) { + $this->logger->warning( + $fname . ' failed due to I/O error for {key}.', + [ 'key' => $key ] + ); + + return WaitConditionLoop::CONDITION_ABORTED; // network partition? + } + + return WaitConditionLoop::CONDITION_CONTINUE; + }, + $timeout + ); + + $code = $loop->invoke(); + $locked = ( $code === $loop::CONDITION_REACHED ); + if ( $locked ) { + $this->locks[$key] = [ 'class' => $rclass, 'depth' => 1 ]; + } elseif ( $code === $loop::CONDITION_TIMED_OUT ) { + $this->logger->warning( + "$fname failed due to timeout for {key}.", + [ 'key' => $key, 'timeout' => $timeout ] + ); + } + + return $locked; + } + + /** + * Release an advisory lock on a key string + * + * @param string $key + * @return bool Success + */ + public function unlock( $key ) { + if ( !isset( $this->locks[$key] ) ) { + return false; + } + + if ( --$this->locks[$key]['depth'] <= 0 ) { + unset( $this->locks[$key] ); + + $ok = $this->doDelete( "{$key}:lock" ); + if ( !$ok ) { + $this->logger->warning( + __METHOD__ . ' failed to release lock for {key}.', + [ 'key' => $key ] + ); + } + + return $ok; + } + + return true; + } + + /** + * Delete all objects expiring before a certain date. + * @param string|int $timestamp The reference date in MW or TS_UNIX format + * @param callable|null $progress Optional, a function which will be called + * regularly during long-running operations with the percentage progress + * as the first parameter. [optional] + * @param int $limit Maximum number of keys to delete [default: INF] + * + * @return bool Success; false if unimplemented + */ + public function deleteObjectsExpiringBefore( + $timestamp, + callable $progress = null, + $limit = INF + ) { + return false; + } + + /** + * Get an associative array containing the item for each of the keys that have items. + * @param string[] $keys List of keys; can be a map of (unused => key) for convenience + * @param int $flags Bitfield; supports READ_LATEST [optional] + * @return mixed[] Map of (key => value) for existing keys; preserves the order of $keys + */ + public function getMulti( array $keys, $flags = 0 ) { + $foundByKey = $this->doGetMulti( $keys, $flags ); + + $res = []; + foreach ( $keys as $key ) { + // Resolve one blob at a time (avoids too much I/O at once) + if ( array_key_exists( $key, $foundByKey ) ) { + // A value should not appear in the key if a segment is missing + $value = $this->resolveSegments( $key, $foundByKey[$key] ); + if ( $value !== false ) { + $res[$key] = $value; + } + } + } + + return $res; + } + + /** + * Get an associative array containing the item for each of the keys that have items. + * @param string[] $keys List of keys + * @param int $flags Bitfield; supports READ_LATEST [optional] + * @return array Map of (key => value) for existing keys + */ + protected function doGetMulti( array $keys, $flags = 0 ) { + $res = []; + foreach ( $keys as $key ) { + $val = $this->doGet( $key, $flags ); + if ( $val !== false ) { + $res[$key] = $val; + } + } + + return $res; + } + + /** + * Batch insertion/replace + * + * This does not support WRITE_ALLOW_SEGMENTS to avoid excessive read I/O + * + * @param mixed[] $data Map of (key => value) + * @param int $exptime Either an interval in seconds or a unix timestamp for expiry + * @param int $flags Bitfield of BagOStuff::WRITE_* constants (since 1.33) + * @return bool Success + * @since 1.24 + */ + public function setMulti( array $data, $exptime = 0, $flags = 0 ) { + if ( ( $flags & self::WRITE_ALLOW_SEGMENTS ) === self::WRITE_ALLOW_SEGMENTS ) { + throw new InvalidArgumentException( __METHOD__ . ' got WRITE_ALLOW_SEGMENTS' ); + } + return $this->doSetMulti( $data, $exptime, $flags ); + } + + /** + * @param mixed[] $data Map of (key => value) + * @param int $exptime Either an interval in seconds or a unix timestamp for expiry + * @param int $flags Bitfield of BagOStuff::WRITE_* constants + * @return bool Success + */ + protected function doSetMulti( array $data, $exptime = 0, $flags = 0 ) { + $res = true; + foreach ( $data as $key => $value ) { + $res = $this->doSet( $key, $value, $exptime, $flags ) && $res; + } + return $res; + } + + /** + * Batch deletion + * + * This does not support WRITE_ALLOW_SEGMENTS to avoid excessive read I/O + * + * @param string[] $keys List of keys + * @param int $flags Bitfield of BagOStuff::WRITE_* constants + * @return bool Success + * @since 1.33 + */ + public function deleteMulti( array $keys, $flags = 0 ) { + if ( ( $flags & self::WRITE_ALLOW_SEGMENTS ) === self::WRITE_ALLOW_SEGMENTS ) { + throw new InvalidArgumentException( __METHOD__ . ' got WRITE_ALLOW_SEGMENTS' ); + } + return $this->doDeleteMulti( $keys, $flags ); + } + + /** + * @param string[] $keys List of keys + * @param int $flags Bitfield of BagOStuff::WRITE_* constants + * @return bool Success + */ + protected function doDeleteMulti( array $keys, $flags = 0 ) { + $res = true; + foreach ( $keys as $key ) { + $res = $this->doDelete( $key, $flags ) && $res; + } + return $res; + } + + /** + * Change the expiration of multiple keys that exist + * + * @param string[] $keys List of keys + * @param int $exptime TTL or UNIX timestamp + * @param int $flags Bitfield of BagOStuff::WRITE_* constants (since 1.33) + * @return bool Success + * @see BagOStuff::changeTTL() + * + * @since 1.34 + */ + public function changeTTLMulti( array $keys, $exptime, $flags = 0 ) { + $res = true; + foreach ( $keys as $key ) { + $res = $this->doChangeTTL( $key, $exptime, $flags ) && $res; + } + + return $res; + } + + /** + * Decrease stored value of $key by $value while preserving its TTL + * @param string $key + * @param int $value Value to subtract from $key (default: 1) [optional] + * @return int|bool New value or false on failure + */ + public function decr( $key, $value = 1 ) { + return $this->incr( $key, -$value ); + } + + /** + * Increase stored value of $key by $value while preserving its TTL + * + * This will create the key with value $init and TTL $ttl instead if not present + * + * @param string $key + * @param int $ttl + * @param int $value + * @param int $init + * @return int|bool New value or false on failure + * @since 1.24 + */ + public function incrWithInit( $key, $ttl, $value = 1, $init = 1 ) { + $this->clearLastError(); + $newValue = $this->incr( $key, $value ); + if ( $newValue === false && !$this->getLastError() ) { + // No key set; initialize + $newValue = $this->add( $key, (int)$init, $ttl ) ? $init : false; + if ( $newValue === false && !$this->getLastError() ) { + // Raced out initializing; increment + $newValue = $this->incr( $key, $value ); + } + } + + return $newValue; + } + + /** + * Get and reassemble the chunks of blob at the given key + * + * @param string $key + * @param mixed $mainValue + * @return string|null|bool The combined string, false if missing, null on error + */ + final protected function resolveSegments( $key, $mainValue ) { + if ( SerializedValueContainer::isUnified( $mainValue ) ) { + return $this->unserialize( $mainValue->{SerializedValueContainer::UNIFIED_DATA} ); + } + + if ( SerializedValueContainer::isSegmented( $mainValue ) ) { + $orderedKeys = array_map( + function ( $segmentHash ) use ( $key ) { + return $this->makeGlobalKey( self::SEGMENT_COMPONENT, $key, $segmentHash ); + }, + $mainValue->{SerializedValueContainer::SEGMENTED_HASHES} + ); + + $segmentsByKey = $this->doGetMulti( $orderedKeys ); + + $parts = []; + foreach ( $orderedKeys as $segmentKey ) { + if ( isset( $segmentsByKey[$segmentKey] ) ) { + $parts[] = $segmentsByKey[$segmentKey]; + } else { + return false; // missing segment + } + } + + return $this->unserialize( implode( '', $parts ) ); + } + + return $mainValue; + } + + /** + * Get the "last error" registered; clearLastError() should be called manually + * @return int ERR_* constant for the "last error" registry + * @since 1.23 + */ + public function getLastError() { + return $this->lastError; + } + + /** + * Clear the "last error" registry + * @since 1.23 + */ + public function clearLastError() { + $this->lastError = self::ERR_NONE; + } + + /** + * Set the "last error" registry + * @param int $err ERR_* constant + * @since 1.23 + */ + protected function setLastError( $err ) { + $this->lastError = $err; + } + + /** + * Let a callback be run to avoid wasting time on special blocking calls + * + * The callbacks may or may not be called ever, in any particular order. + * They are likely to be invoked when something WRITE_SYNC is used used. + * They should follow a caching pattern as shown below, so that any code + * using the work will get it's result no matter what happens. + * @code + * $result = null; + * $workCallback = function () use ( &$result ) { + * if ( !$result ) { + * $result = .... + * } + * return $result; + * } + * @endcode + * + * @param callable $workCallback + * @since 1.28 + */ + final public function addBusyCallback( callable $workCallback ) { + $this->busyCallbacks[] = $workCallback; + } + + /** + * @param int $exptime + * @return bool + */ + final protected function expiryIsRelative( $exptime ) { + return ( $exptime != 0 && $exptime < ( 10 * self::TTL_YEAR ) ); + } + + /** + * Convert an optionally relative timestamp to an absolute time + * + * The input value will be cast to an integer and interpreted as follows: + * - zero: no expiry; return zero (e.g. TTL_INDEFINITE) + * - negative: relative TTL; return UNIX timestamp offset by this value + * - positive (< 10 years): relative TTL; return UNIX timestamp offset by this value + * - positive (>= 10 years): absolute UNIX timestamp; return this value + * + * @param int $exptime Absolute TTL or 0 for indefinite + * @return int + */ + final protected function convertToExpiry( $exptime ) { + return $this->expiryIsRelative( $exptime ) + ? (int)$this->getCurrentTime() + $exptime + : $exptime; + } + + /** + * Convert an optionally absolute expiry time to a relative time. If an + * absolute time is specified which is in the past, use a short expiry time. + * + * @param int $exptime + * @return int + */ + final protected function convertToRelative( $exptime ) { + return $this->expiryIsRelative( $exptime ) + ? (int)$exptime + : max( $exptime - (int)$this->getCurrentTime(), 1 ); + } + + /** + * Check if a value is an integer + * + * @param mixed $value + * @return bool + */ + final protected function isInteger( $value ) { + if ( is_int( $value ) ) { + return true; + } elseif ( !is_string( $value ) ) { + return false; + } + + $integer = (int)$value; + + return ( $value === (string)$integer ); + } + + /** + * Construct a cache key. + * + * @param string $keyspace + * @param array $args + * @return string Colon-delimited list of $keyspace followed by escaped components of $args + * @since 1.27 + */ + public function makeKeyInternal( $keyspace, $args ) { + $key = $keyspace; + foreach ( $args as $arg ) { + $key .= ':' . str_replace( ':', '%3A', $arg ); + } + return strtr( $key, ' ', '_' ); + } + + /** + * Make a global cache key. + * + * @param string $class Key class + * @param string ...$components Key components (starting with a key collection name) + * @return string Colon-delimited list of $keyspace followed by escaped components + * @since 1.27 + */ + public function makeGlobalKey( $class, ...$components ) { + return $this->makeKeyInternal( 'global', func_get_args() ); + } + + /** + * Make a cache key, scoped to this instance's keyspace. + * + * @param string $class Key class + * @param string ...$components Key components (starting with a key collection name) + * @return string Colon-delimited list of $keyspace followed by escaped components + * @since 1.27 + */ + public function makeKey( $class, ...$components ) { + return $this->makeKeyInternal( $this->keyspace, func_get_args() ); + } + + /** + * @param int $flag ATTR_* class constant + * @return int QOS_* class constant + * @since 1.28 + */ + public function getQoS( $flag ) { + return $this->attrMap[$flag] ?? self::QOS_UNKNOWN; + } + + /** + * @return int|float The chunk size, in bytes, of segmented objects (INF for no limit) + * @since 1.34 + */ + public function getSegmentationSize() { + return $this->segmentationSize; + } + + /** + * @return int|float Maximum total segmented object size in bytes (INF for no limit) + * @since 1.34 + */ + public function getSegmentedValueMaxSize() { + return $this->segmentedValueMaxSize; + } + + /** + * @param mixed $value + * @return string|int String/integer representation + * @note Special handling is usually needed for integers so incr()/decr() work + */ + protected function serialize( $value ) { + return is_int( $value ) ? $value : serialize( $value ); + } + + /** + * @param string|int $value + * @return mixed Original value or false on error + * @note Special handling is usually needed for integers so incr()/decr() work + */ + protected function unserialize( $value ) { + return $this->isInteger( $value ) ? (int)$value : unserialize( $value ); + } + + /** + * @param string $text + */ + protected function debug( $text ) { + if ( $this->debugMode ) { + $this->logger->debug( "{class} debug: $text", [ 'class' => static::class ] ); + } + } +} diff --git a/includes/libs/objectcache/MemcachedBagOStuff.php b/includes/libs/objectcache/MemcachedBagOStuff.php index 3d6bd16129..9f1c98ab58 100644 --- a/includes/libs/objectcache/MemcachedBagOStuff.php +++ b/includes/libs/objectcache/MemcachedBagOStuff.php @@ -26,77 +26,12 @@ * * @ingroup Cache */ -class MemcachedBagOStuff extends BagOStuff { - /** @var MemcachedClient|Memcached */ - protected $client; - +abstract class MemcachedBagOStuff extends MediumSpecificBagOStuff { function __construct( array $params ) { parent::__construct( $params ); $this->attrMap[self::ATTR_SYNCWRITES] = self::QOS_SYNCWRITES_BE; // unreliable - } - - /** - * Fill in some defaults for missing keys in $params. - * - * @param array $params - * @return array - */ - protected function applyDefaultParams( $params ) { - return $params + [ - 'compress_threshold' => 1500, - 'connect_timeout' => 0.5, - 'debug' => false - ]; - } - - protected function doGet( $key, $flags = 0, &$casToken = null ) { - return $this->client->get( $this->validateKeyEncoding( $key ), $casToken ); - } - - public function set( $key, $value, $exptime = 0, $flags = 0 ) { - return $this->client->set( $this->validateKeyEncoding( $key ), $value, - $this->fixExpiry( $exptime ) ); - } - - protected function cas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) { - return $this->client->cas( $casToken, $this->validateKeyEncoding( $key ), - $value, $this->fixExpiry( $exptime ) ); - } - - public function delete( $key, $flags = 0 ) { - return $this->client->delete( $this->validateKeyEncoding( $key ) ); - } - - public function add( $key, $value, $exptime = 0, $flags = 0 ) { - return $this->client->add( $this->validateKeyEncoding( $key ), $value, - $this->fixExpiry( $exptime ) ); - } - - public function incr( $key, $value = 1 ) { - $n = $this->client->incr( $this->validateKeyEncoding( $key ), $value ); - - return ( $n !== false && $n !== null ) ? $n : false; - } - - public function decr( $key, $value = 1 ) { - $n = $this->client->decr( $this->validateKeyEncoding( $key ), $value ); - - return ( $n !== false && $n !== null ) ? $n : false; - } - - public function changeTTL( $key, $exptime = 0, $flags = 0 ) { - return $this->client->touch( $this->validateKeyEncoding( $key ), - $this->fixExpiry( $exptime ) ); - } - - /** - * Get the underlying client object. This is provided for debugging - * purposes. - * @return MemcachedClient|Memcached - */ - public function getClient() { - return $this->client; + $this->segmentationSize = $params['maxPreferedKeySize'] ?? 917504; // < 1MiB } /** diff --git a/includes/libs/objectcache/MemcachedClient.php b/includes/libs/objectcache/MemcachedClient.php index 937ca5546d..eecf7ec799 100644 --- a/includes/libs/objectcache/MemcachedClient.php +++ b/includes/libs/objectcache/MemcachedClient.php @@ -278,6 +278,23 @@ class MemcachedClient { } // }}} + + /** + * @param mixed $value + * @return string|integer + */ + public function serialize( $value ) { + return serialize( $value ); + } + + /** + * @param string $value + * @return mixed + */ + public function unserialize( $value ) { + return unserialize( $value ); + } + // {{{ add() /** @@ -503,7 +520,8 @@ class MemcachedClient { if ( $this->_debug ) { foreach ( $val as $k => $v ) { - $this->_debugprint( sprintf( "MemCache: sock %s got %s", serialize( $sock ), $k ) ); + $this->_debugprint( + sprintf( "MemCache: sock %s got %s", $this->serialize( $sock ), $k ) ); } } @@ -1018,7 +1036,7 @@ class MemcachedClient { * yet read "END"), these 2 calls would collide. */ if ( $flags & self::SERIALIZED ) { - $ret[$rkey] = unserialize( $ret[$rkey] ); + $ret[$rkey] = $this->unserialize( $ret[$rkey] ); } elseif ( $flags & self::INTVAL ) { $ret[$rkey] = intval( $ret[$rkey] ); } @@ -1072,7 +1090,7 @@ class MemcachedClient { if ( is_int( $val ) ) { $flags |= self::INTVAL; } elseif ( !is_scalar( $val ) ) { - $val = serialize( $val ); + $val = $this->serialize( $val ); $flags |= self::SERIALIZED; if ( $this->_debug ) { $this->_debugprint( sprintf( "client: serializing data as it is not scalar" ) ); diff --git a/includes/libs/objectcache/MemcachedPeclBagOStuff.php b/includes/libs/objectcache/MemcachedPeclBagOStuff.php index db9450387f..cc7ee2a5f5 100644 --- a/includes/libs/objectcache/MemcachedPeclBagOStuff.php +++ b/includes/libs/objectcache/MemcachedPeclBagOStuff.php @@ -27,90 +27,139 @@ * @ingroup Cache */ class MemcachedPeclBagOStuff extends MemcachedBagOStuff { + /** @var Memcached */ + protected $syncClient; + /** @var Memcached|null */ + protected $asyncClient; + + /** @var bool Whether the non-buffering client is locked from use */ + protected $syncClientIsBuffering = false; + /** @var bool Whether the non-buffering client should be flushed before use */ + protected $hasUnflushedChanges = false; + + /** @var array Memcached options */ + private static $OPTS_SYNC_WRITES = [ + Memcached::OPT_NO_BLOCK => false, // async I/O (using TCP buffers) + Memcached::OPT_BUFFER_WRITES => false // libmemcached buffers + ]; + /** @var array Memcached options */ + private static $OPTS_ASYNC_WRITES = [ + Memcached::OPT_NO_BLOCK => true, // async I/O (using TCP buffers) + Memcached::OPT_BUFFER_WRITES => true // libmemcached buffers + ]; /** * Available parameters are: - * - servers: The list of IP:port combinations holding the memcached servers. - * - persistent: Whether to use a persistent connection - * - compress_threshold: The minimum size an object must be before it is compressed - * - timeout: The read timeout in microseconds - * - connect_timeout: The connect timeout in seconds - * - retry_timeout: Time in seconds to wait before retrying a failed connect attempt - * - server_failure_limit: Limit for server connect failures before it is removed - * - serializer: May be either "php" or "igbinary". Igbinary produces more compact - * values, but serialization is much slower unless the php.ini option - * igbinary.compact_strings is off. - * - use_binary_protocol Whether to enable the binary protocol (default is ASCII) (boolean) + * - servers: List of IP:port combinations holding the memcached servers. + * - persistent: Whether to use a persistent connection + * - compress_threshold: The minimum size an object must be before it is compressed + * - timeout: The read timeout in microseconds + * - connect_timeout: The connect timeout in seconds + * - retry_timeout: Time in seconds to wait before retrying a failed connect attempt + * - server_failure_limit: Limit for server connect failures before it is removed + * - serializer: Either "php" or "igbinary". Igbinary produces more compact + * values, but serialization is much slower unless the php.ini + * option igbinary.compact_strings is off. + * - use_binary_protocol Whether to enable the binary protocol (default is ASCII) + * - allow_tcp_nagle_delay Whether to permit Nagle's algorithm for reducing packet count * @param array $params - * @throws InvalidArgumentException */ function __construct( $params ) { parent::__construct( $params ); - $params = $this->applyDefaultParams( $params ); + + // Default class-specific parameters + $params += [ + 'compress_threshold' => 1500, + 'connect_timeout' => 0.5, + 'serializer' => 'php', + 'use_binary_protocol' => false, + 'allow_tcp_nagle_delay' => true + ]; if ( $params['persistent'] ) { // The pool ID must be unique to the server/option combination. // The Memcached object is essentially shared for each pool ID. // We can only reuse a pool ID if we keep the config consistent. - $this->client = new Memcached( md5( serialize( $params ) ) ); - if ( count( $this->client->getServerList() ) ) { - $this->logger->debug( __METHOD__ . ": persistent Memcached object already loaded." ); - return; // already initialized; don't add duplicate servers - } + $connectionPoolId = md5( serialize( $params ) ); + $syncClient = new Memcached( "$connectionPoolId-sync" ); + // Avoid clobbering the main thread-shared Memcached instance + $asyncClient = new Memcached( "$connectionPoolId-async" ); } else { - $this->client = new Memcached; + $syncClient = new Memcached(); + $asyncClient = null; } - if ( $params['use_binary_protocol'] ) { - $this->client->setOption( Memcached::OPT_BINARY_PROTOCOL, true ); - } - - if ( isset( $params['retry_timeout'] ) ) { - $this->client->setOption( Memcached::OPT_RETRY_TIMEOUT, $params['retry_timeout'] ); - } - - if ( isset( $params['server_failure_limit'] ) ) { - $this->client->setOption( Memcached::OPT_SERVER_FAILURE_LIMIT, $params['server_failure_limit'] ); + $this->initializeClient( $syncClient, $params, self::$OPTS_SYNC_WRITES ); + if ( $asyncClient ) { + $this->initializeClient( $asyncClient, $params, self::$OPTS_ASYNC_WRITES ); } + // Set the main client and any dedicated one for buffered writes + $this->syncClient = $syncClient; + $this->asyncClient = $asyncClient; // The compression threshold is an undocumented php.ini option for some // reason. There's probably not much harm in setting it globally, for // compatibility with the settings for the PHP client. ini_set( 'memcached.compression_threshold', $params['compress_threshold'] ); + } - // Set timeouts - $this->client->setOption( Memcached::OPT_CONNECT_TIMEOUT, $params['connect_timeout'] * 1000 ); - $this->client->setOption( Memcached::OPT_SEND_TIMEOUT, $params['timeout'] ); - $this->client->setOption( Memcached::OPT_RECV_TIMEOUT, $params['timeout'] ); - $this->client->setOption( Memcached::OPT_POLL_TIMEOUT, $params['timeout'] / 1000 ); - - // Set libketama mode since it's recommended by the documentation and - // is as good as any. There's no way to configure libmemcached to use - // hashes identical to the ones currently in use by the PHP client, and - // even implementing one of the libmemcached hashes in pure PHP for - // forwards compatibility would require MemcachedClient::get_sock() to be - // rewritten. - $this->client->setOption( Memcached::OPT_LIBKETAMA_COMPATIBLE, true ); - - // Set the serializer - switch ( $params['serializer'] ) { - case 'php': - $this->client->setOption( Memcached::OPT_SERIALIZER, Memcached::SERIALIZER_PHP ); - break; - case 'igbinary': - if ( !Memcached::HAVE_IGBINARY ) { - throw new InvalidArgumentException( - __CLASS__ . ': the igbinary extension is not available ' . - 'but igbinary serialization was requested.' - ); - } - $this->client->setOption( Memcached::OPT_SERIALIZER, Memcached::SERIALIZER_IGBINARY ); - break; - default: - throw new InvalidArgumentException( - __CLASS__ . ': invalid value for serializer parameter' + /** + * Initialize the client only if needed and reuse it otherwise. + * This avoids duplicate servers in the list and new connections. + * + * @param Memcached $client + * @param array $params + * @param array $options Base options for Memcached::setOptions() + * @throws RuntimeException + */ + private function initializeClient( Memcached $client, array $params, array $options ) { + if ( $client->getServerList() ) { + $this->logger->debug( __METHOD__ . ": pre-initialized client instance." ); + + return; // preserve persistent handle + } + + $this->logger->debug( __METHOD__ . ": initializing new client instance." ); + + $options += [ + Memcached::OPT_NO_BLOCK => false, + Memcached::OPT_BUFFER_WRITES => false, + // Network protocol (ASCII or binary) + Memcached::OPT_BINARY_PROTOCOL => $params['use_binary_protocol'], + // Set various network timeouts + Memcached::OPT_CONNECT_TIMEOUT => $params['connect_timeout'] * 1000, + Memcached::OPT_SEND_TIMEOUT => $params['timeout'], + Memcached::OPT_RECV_TIMEOUT => $params['timeout'], + Memcached::OPT_POLL_TIMEOUT => $params['timeout'] / 1000, + // Avoid pointless delay when sending/fetching large blobs + Memcached::OPT_TCP_NODELAY => !$params['allow_tcp_nagle_delay'], + // Set libketama mode since it's recommended by the documentation + Memcached::OPT_LIBKETAMA_COMPATIBLE => true + ]; + if ( isset( $params['retry_timeout'] ) ) { + $options[Memcached::OPT_RETRY_TIMEOUT] = $params['retry_timeout']; + } + if ( isset( $params['server_failure_limit'] ) ) { + $options[Memcached::OPT_SERVER_FAILURE_LIMIT] = $params['server_failure_limit']; + } + if ( $params['serializer'] === 'php' ) { + $options[Memcached::OPT_SERIALIZER] = Memcached::SERIALIZER_PHP; + } elseif ( $params['serializer'] === 'igbinary' ) { + if ( !Memcached::HAVE_IGBINARY ) { + throw new RuntimeException( + __CLASS__ . ': the igbinary extension is not available ' . + 'but igbinary serialization was requested.' ); + } + $options[Memcached::OPT_SERIALIZER] = Memcached::SERIALIZER_IGBINARY; + } + + if ( !$client->setOptions( $options ) ) { + throw new RuntimeException( + "Invalid options: " . json_encode( $options, JSON_PRETTY_PRINT ) + ); } + $servers = []; foreach ( $params['servers'] as $host ) { if ( preg_match( '/^\[(.+)\]:(\d+)$/', $host, $m ) ) { @@ -121,31 +170,20 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff { $servers[] = [ $host, false ]; // (ip or path, port) } } - $this->client->addServers( $servers ); - } - - protected function applyDefaultParams( $params ) { - $params = parent::applyDefaultParams( $params ); - - if ( !isset( $params['use_binary_protocol'] ) ) { - $params['use_binary_protocol'] = false; - } - if ( !isset( $params['serializer'] ) ) { - $params['serializer'] = 'php'; + if ( !$client->addServers( $servers ) ) { + throw new RuntimeException( "Failed to inject server address list" ); } - - return $params; } - /** - * @suppress PhanTypeNonVarPassByRef - */ protected function doGet( $key, $flags = 0, &$casToken = null ) { $this->debug( "get($key)" ); + + $client = $this->acquireSyncClient(); if ( defined( Memcached::class . '::GET_EXTENDED' ) ) { // v3.0.0 + /** @noinspection PhpUndefinedClassConstantInspection */ $flags = Memcached::GET_EXTENDED; - $res = $this->client->get( $this->validateKeyEncoding( $key ), null, $flags ); + $res = $client->get( $this->validateKeyEncoding( $key ), null, $flags ); if ( is_array( $res ) ) { $result = $res['value']; $casToken = $res['cas']; @@ -154,51 +192,77 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff { $casToken = null; } } else { - $result = $this->client->get( $this->validateKeyEncoding( $key ), null, $casToken ); + $result = $client->get( $this->validateKeyEncoding( $key ), null, $casToken ); } - $result = $this->checkResult( $key, $result ); - return $result; + + return $this->checkResult( $key, $result ); } - public function set( $key, $value, $exptime = 0, $flags = 0 ) { + protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) { $this->debug( "set($key)" ); - $result = parent::set( $key, $value, $exptime, $flags = 0 ); - if ( $result === false && $this->client->getResultCode() === Memcached::RES_NOTSTORED ) { + + $client = $this->acquireSyncClient(); + $result = $client->set( + $this->validateKeyEncoding( $key ), + $value, + $this->fixExpiry( $exptime ) + ); + + return ( $result === false && $client->getResultCode() === Memcached::RES_NOTSTORED ) // "Not stored" is always used as the mcrouter response with AllAsyncRoute - return true; - } - return $this->checkResult( $key, $result ); + ? true + : $this->checkResult( $key, $result ); } protected function cas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) { $this->debug( "cas($key)" ); - return $this->checkResult( $key, parent::cas( $casToken, $key, $value, $exptime, $flags ) ); + + $result = $this->acquireSyncClient()->cas( + $casToken, + $this->validateKeyEncoding( $key ), + $value, $this->fixExpiry( $exptime ) + ); + + return $this->checkResult( $key, $result ); } - public function delete( $key, $flags = 0 ) { + protected function doDelete( $key, $flags = 0 ) { $this->debug( "delete($key)" ); - $result = parent::delete( $key ); - if ( $result === false && $this->client->getResultCode() === Memcached::RES_NOTFOUND ) { + + $client = $this->acquireSyncClient(); + $result = $client->delete( $this->validateKeyEncoding( $key ) ); + + return ( $result === false && $client->getResultCode() === Memcached::RES_NOTFOUND ) // "Not found" is counted as success in our interface - return true; - } - return $this->checkResult( $key, $result ); + ? true + : $this->checkResult( $key, $result ); } public function add( $key, $value, $exptime = 0, $flags = 0 ) { $this->debug( "add($key)" ); - return $this->checkResult( $key, parent::add( $key, $value, $exptime ) ); + + $result = $this->acquireSyncClient()->add( + $this->validateKeyEncoding( $key ), + $value, + $this->fixExpiry( $exptime ) + ); + + return $this->checkResult( $key, $result ); } public function incr( $key, $value = 1 ) { $this->debug( "incr($key)" ); - $result = $this->client->increment( $key, $value ); + + $result = $this->acquireSyncClient()->increment( $key, $value ); + return $this->checkResult( $key, $result ); } public function decr( $key, $value = 1 ) { $this->debug( "decr($key)" ); - $result = $this->client->decrement( $key, $value ); + + $result = $this->acquireSyncClient()->decrement( $key, $value ); + return $this->checkResult( $key, $result ); } @@ -217,22 +281,25 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff { if ( $result !== false ) { return $result; } - switch ( $this->client->getResultCode() ) { + + $client = $this->syncClient; + switch ( $client->getResultCode() ) { case Memcached::RES_SUCCESS: break; case Memcached::RES_DATA_EXISTS: case Memcached::RES_NOTSTORED: case Memcached::RES_NOTFOUND: - $this->debug( "result: " . $this->client->getResultMessage() ); + $this->debug( "result: " . $client->getResultMessage() ); break; default: - $msg = $this->client->getResultMessage(); + $msg = $client->getResultMessage(); $logCtx = []; if ( $key !== false ) { - $server = $this->client->getServerByKey( $key ); + $server = $client->getServerByKey( $key ); $logCtx['memcached-server'] = "{$server['host']}:{$server['port']}"; $logCtx['memcached-key'] = $key; - $msg = "Memcached error for key \"{memcached-key}\" on server \"{memcached-server}\": $msg"; + $msg = "Memcached error for key \"{memcached-key}\" " . + "on server \"{memcached-server}\": $msg"; } else { $msg = "Memcached error: $msg"; } @@ -242,27 +309,151 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff { return $result; } - public function getMulti( array $keys, $flags = 0 ) { + protected function doGetMulti( array $keys, $flags = 0 ) { $this->debug( 'getMulti(' . implode( ', ', $keys ) . ')' ); + foreach ( $keys as $key ) { $this->validateKeyEncoding( $key ); } - $result = $this->client->getMulti( $keys ) ?: []; + + // The PECL implementation uses "gets" which works as well as a pipeline + $result = $this->acquireSyncClient()->getMulti( $keys ) ?: []; + return $this->checkResult( false, $result ); } - public function setMulti( array $data, $exptime = 0, $flags = 0 ) { + protected function doSetMulti( array $data, $exptime = 0, $flags = 0 ) { $this->debug( 'setMulti(' . implode( ', ', array_keys( $data ) ) . ')' ); + + $exptime = $this->fixExpiry( $exptime ); foreach ( array_keys( $data ) as $key ) { $this->validateKeyEncoding( $key ); } - $result = $this->client->setMulti( $data, $this->fixExpiry( $exptime ) ); + + // The PECL implementation is a naïve for-loop so use async I/O to pipeline; + // https://github.com/php-memcached-dev/php-memcached/blob/master/php_memcached.c#L1852 + if ( ( $flags & self::WRITE_BACKGROUND ) == self::WRITE_BACKGROUND ) { + $client = $this->acquireAsyncClient(); + $result = $client->setMulti( $data, $exptime ); + $this->releaseAsyncClient( $client ); + } else { + $result = $this->acquireSyncClient()->setMulti( $data, $exptime ); + } + + return $this->checkResult( false, $result ); + } + + protected function doDeleteMulti( array $keys, $flags = 0 ) { + $this->debug( 'deleteMulti(' . implode( ', ', $keys ) . ')' ); + + foreach ( $keys as $key ) { + $this->validateKeyEncoding( $key ); + } + + // The PECL implementation is a naïve for-loop so use async I/O to pipeline; + // https://github.com/php-memcached-dev/php-memcached/blob/7443d16d02fb73cdba2e90ae282446f80969229c/php_memcached.c#L1852 + if ( ( $flags & self::WRITE_BACKGROUND ) == self::WRITE_BACKGROUND ) { + $client = $this->acquireAsyncClient(); + $resultArray = $client->deleteMulti( $keys ) ?: []; + $this->releaseAsyncClient( $client ); + } else { + $resultArray = $this->acquireSyncClient()->deleteMulti( $keys ) ?: []; + } + + $result = true; + foreach ( $resultArray as $code ) { + if ( !in_array( $code, [ true, Memcached::RES_NOTFOUND ], true ) ) { + // "Not found" is counted as success in our interface + $result = false; + } + } + return $this->checkResult( false, $result ); } - public function changeTTL( $key, $expiry = 0, $flags = 0 ) { + protected function doChangeTTL( $key, $exptime, $flags ) { $this->debug( "touch($key)" ); - $result = $this->client->touch( $key, $expiry ); + + $result = $this->acquireSyncClient()->touch( $key, $this->fixExpiry( $exptime ) ); + return $this->checkResult( $key, $result ); } + + protected function serialize( $value ) { + if ( is_int( $value ) ) { + return $value; + } + + $serializer = $this->syncClient->getOption( Memcached::OPT_SERIALIZER ); + if ( $serializer === Memcached::SERIALIZER_PHP ) { + return serialize( $value ); + } elseif ( $serializer === Memcached::SERIALIZER_IGBINARY ) { + return igbinary_serialize( $value ); + } + + throw new UnexpectedValueException( __METHOD__ . ": got serializer '$serializer'." ); + } + + protected function unserialize( $value ) { + if ( $this->isInteger( $value ) ) { + return (int)$value; + } + + $serializer = $this->syncClient->getOption( Memcached::OPT_SERIALIZER ); + if ( $serializer === Memcached::SERIALIZER_PHP ) { + return unserialize( $value ); + } elseif ( $serializer === Memcached::SERIALIZER_IGBINARY ) { + return igbinary_unserialize( $value ); + } + + throw new UnexpectedValueException( __METHOD__ . ": got serializer '$serializer'." ); + } + + /** + * @return Memcached + */ + private function acquireSyncClient() { + if ( $this->syncClientIsBuffering ) { + throw new RuntimeException( "The main (unbuffered I/O) client is locked" ); + } + + if ( $this->hasUnflushedChanges ) { + // Force a synchronous flush of async writes so that their changes are visible + $this->syncClient->fetch(); + if ( $this->asyncClient ) { + $this->asyncClient->fetch(); + } + $this->hasUnflushedChanges = false; + } + + return $this->syncClient; + } + + /** + * @return Memcached + */ + private function acquireAsyncClient() { + if ( $this->asyncClient ) { + return $this->asyncClient; // dedicated buffering instance + } + + // Modify the main instance to temporarily buffer writes + $this->syncClientIsBuffering = true; + $this->syncClient->setOptions( self::$OPTS_ASYNC_WRITES ); + + return $this->syncClient; + } + + /** + * @param Memcached $client + */ + private function releaseAsyncClient( $client ) { + $this->hasUnflushedChanges = true; + + if ( !$this->asyncClient ) { + // This is the main instance; make it stop buffering writes again + $client->setOptions( self::$OPTS_SYNC_WRITES ); + $this->syncClientIsBuffering = false; + } + } } diff --git a/includes/libs/objectcache/MemcachedPhpBagOStuff.php b/includes/libs/objectcache/MemcachedPhpBagOStuff.php index 8f190c3b76..b1d5d29f16 100644 --- a/includes/libs/objectcache/MemcachedPhpBagOStuff.php +++ b/includes/libs/objectcache/MemcachedPhpBagOStuff.php @@ -27,10 +27,12 @@ * @ingroup Cache */ class MemcachedPhpBagOStuff extends MemcachedBagOStuff { + /** @var MemcachedClient */ + protected $client; + /** * Available parameters are: * - servers: The list of IP:port combinations holding the memcached servers. - * - debug: Whether to set the debug flag in the underlying client. * - persistent: Whether to use a persistent connection * - compress_threshold: The minimum size an object must be before it is compressed * - timeout: The read timeout in microseconds @@ -40,22 +42,89 @@ class MemcachedPhpBagOStuff extends MemcachedBagOStuff { */ function __construct( $params ) { parent::__construct( $params ); - $params = $this->applyDefaultParams( $params ); + + // Default class-specific parameters + $params += [ + 'compress_threshold' => 1500, + 'connect_timeout' => 0.5 + ]; $this->client = new MemcachedClient( $params ); $this->client->set_servers( $params['servers'] ); - $this->client->set_debug( $params['debug'] ); } - public function setDebug( $debug ) { - $this->client->set_debug( $debug ); + public function setDebug( $enabled ) { + parent::debug( $enabled ); + $this->client->set_debug( $enabled ); + } + + protected function doGet( $key, $flags = 0, &$casToken = null ) { + $casToken = null; + + return $this->client->get( $this->validateKeyEncoding( $key ), $casToken ); + } + + protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) { + return $this->client->set( + $this->validateKeyEncoding( $key ), + $value, + $this->fixExpiry( $exptime ) + ); + } + + protected function doDelete( $key, $flags = 0 ) { + return $this->client->delete( $this->validateKeyEncoding( $key ) ); + } + + public function add( $key, $value, $exptime = 0, $flags = 0 ) { + return $this->client->add( + $this->validateKeyEncoding( $key ), + $value, + $this->fixExpiry( $exptime ) + ); + } + + protected function cas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) { + return $this->client->cas( + $casToken, + $this->validateKeyEncoding( $key ), + $value, + $this->fixExpiry( $exptime ) + ); } - public function getMulti( array $keys, $flags = 0 ) { + public function incr( $key, $value = 1 ) { + $n = $this->client->incr( $this->validateKeyEncoding( $key ), $value ); + + return ( $n !== false && $n !== null ) ? $n : false; + } + + public function decr( $key, $value = 1 ) { + $n = $this->client->decr( $this->validateKeyEncoding( $key ), $value ); + + return ( $n !== false && $n !== null ) ? $n : false; + } + + protected function doChangeTTL( $key, $exptime, $flags ) { + return $this->client->touch( + $this->validateKeyEncoding( $key ), + $this->fixExpiry( $exptime ) + ); + } + + protected function doGetMulti( array $keys, $flags = 0 ) { foreach ( $keys as $key ) { $this->validateKeyEncoding( $key ); } return $this->client->get_multi( $keys ); } + + protected function serialize( $value ) { + return is_int( $value ) ? $value : $this->client->serialize( $value ); + } + + protected function unserialize( $value ) { + return $this->isInteger( $value ) ? (int)$value : $this->client->unserialize( $value ); + } } diff --git a/includes/libs/objectcache/MultiWriteBagOStuff.php b/includes/libs/objectcache/MultiWriteBagOStuff.php index 1ed91ea298..d150880750 100644 --- a/includes/libs/objectcache/MultiWriteBagOStuff.php +++ b/includes/libs/objectcache/MultiWriteBagOStuff.php @@ -40,7 +40,8 @@ class MultiWriteBagOStuff extends BagOStuff { /** @var int[] List of all backing cache indexes */ protected $cacheIndexes = []; - const UPGRADE_TTL = 3600; // TTL when a key is copied to a higher cache tier + /** @var int TTL when a key is copied to a higher cache tier */ + private static $UPGRADE_TTL = 3600; /** * $params include: @@ -97,9 +98,10 @@ class MultiWriteBagOStuff extends BagOStuff { $this->cacheIndexes = array_keys( $this->caches ); } - public function setDebug( $debug ) { + public function setDebug( $enabled ) { + parent::setDebug( $enabled ); foreach ( $this->caches as $cache ) { - $cache->setDebug( $debug ); + $cache->setDebug( $enabled ); } } @@ -130,7 +132,8 @@ class MultiWriteBagOStuff extends BagOStuff { $missIndexes, $this->asyncWrites, 'set', - [ $key, $value, self::UPGRADE_TTL ] + // @TODO: consider using self::WRITE_ALLOW_SEGMENTS here? + [ $key, $value, self::$UPGRADE_TTL ] ); } @@ -207,18 +210,14 @@ class MultiWriteBagOStuff extends BagOStuff { return $this->caches[0]->unlock( $key ); } - /** - * Delete objects expiring before a certain date. - * - * Succeed if any of the child caches succeed. - * @param string $date - * @param bool|callable $progressCallback - * @return bool - */ - public function deleteObjectsExpiringBefore( $date, $progressCallback = false ) { + public function deleteObjectsExpiringBefore( + $timestamp, + callable $progress = null, + $limit = INF + ) { $ret = false; foreach ( $this->caches as $cache ) { - if ( $cache->deleteObjectsExpiringBefore( $date, $progressCallback ) ) { + if ( $cache->deleteObjectsExpiringBefore( $timestamp, $progress, $limit ) ) { $ret = true; } } @@ -257,6 +256,15 @@ class MultiWriteBagOStuff extends BagOStuff { ); } + public function changeTTLMulti( array $keys, $exptime, $flags = 0 ) { + return $this->doWrite( + $this->cacheIndexes, + $this->usesAsyncWritesGivenFlags( $flags ), + __FUNCTION__, + func_get_args() + ); + } + public function incr( $key, $value = 1 ) { return $this->doWrite( $this->cacheIndexes, @@ -298,7 +306,7 @@ class MultiWriteBagOStuff extends BagOStuff { * @param int[] $indexes List of backing cache indexes * @param bool $asyncWrites * @param string $method Method name of backing caches - * @param array[] $args Arguments to the method of backing caches + * @param array $args Arguments to the method of backing caches * @return bool */ protected function doWrite( $indexes, $asyncWrites, $method, array $args ) { @@ -342,18 +350,26 @@ class MultiWriteBagOStuff extends BagOStuff { } public function makeKeyInternal( $keyspace, $args ) { - return $this->caches[0]->makeKeyInternal( ...func_get_args() ); + return $this->caches[0]->makeKeyInternal( $keyspace, $args ); } - public function makeKey( $class, $component = null ) { + public function makeKey( $class, ...$components ) { return $this->caches[0]->makeKey( ...func_get_args() ); } - public function makeGlobalKey( $class, $component = null ) { + public function makeGlobalKey( $class, ...$components ) { return $this->caches[0]->makeGlobalKey( ...func_get_args() ); } - protected function doGet( $key, $flags = 0, &$casToken = null ) { - throw new LogicException( __METHOD__ . ': proxy class does not need this method.' ); + public function addBusyCallback( callable $workCallback ) { + $this->caches[0]->addBusyCallback( $workCallback ); + } + + public function setMockTime( &$time ) { + parent::setMockTime( $time ); + foreach ( $this->caches as $cache ) { + $cache->setMockTime( $time ); + $cache->setMockTime( $time ); + } } } diff --git a/includes/libs/objectcache/README.md b/includes/libs/objectcache/README.md new file mode 100644 index 0000000000..42bf6360dd --- /dev/null +++ b/includes/libs/objectcache/README.md @@ -0,0 +1,116 @@ +# wikimedia/objectcache + +## Statistics + +Sent to StatsD under MediaWiki's namespace. + +### WANObjectCache + +The default WANObjectCache provided by MediaWikiServices disables these +statistics in processes where `$wgCommandLineMode` is true. + +#### `wanobjectcache.{kClass}.{cache_action_and_result}` + +Call counter from `WANObjectCache::getWithSetCallback()`. + +* Type: Counter. +* Variable `kClass`: The first part of your cache key. +* Variable `result`: One of: + * `"hit.good"`, + * `"hit.refresh"`, + * `"hit.volatile"`, + * `"hit.stale"`, + * `"miss.busy"` (or `"renew.busy"`, if the `minAsOf` is used), + * `"miss.compute"` (or `"renew.busy"`, if the `minAsOf` is used). + +#### `wanobjectcache.{kClass}.regen_set_delay` + +Upon cache miss, this measures the time spent in `WANObjectCache::getWithSetCallback()`, +from the start of the method to right after the new value has been computed by the callback. + +This essentially measures the whole method (including retrieval of any old value, +validation, any locks for `lockTSE`, and the callbacks), except for the time spent +in sending the value to the backend server. + +* Type: Measure (in milliseconds). +* Variable `kClass`: The first part of your cache key. + +#### `wanobjectcache.{kClass}.regen_walltime` + +Upon cache miss, this measures the time spent in `WANObjectCache::getWithSetCallback()` +from the start of the callback to right after the new value has been computed. + +* Type: Measure (in milliseconds). +* Variable `kClass`: The first part of your cache key. + +#### `wanobjectcache.{kClass}.ck_touch.{result}` + +Call counter from `WANObjectCache::touchCheckKey()`. + +* Type: Counter. +* Variable `kClass`: The first part of your cache key. +* Variable `result`: One of `"ok"` or `"error"`. + +#### `wanobjectcache.{kClass}.ck_reset.{result}` + +Call counter from `WANObjectCache::resetCheckKey()`. + +* Type: Counter. +* Variable `kClass`: The first part of your cache key. +* Variable `result`: One of `"ok"` or `"error"`. + +#### `wanobjectcache.{kClass}.delete.{result}` + +Call counter from `WANObjectCache::delete()`. + +* Type: Counter. +* Variable `kClass`: The first part of your cache key. +* Variable `result`: One of `"ok"` or `"error"`. + +#### `wanobjectcache.{kClass}.cooloff_bounce` + +Upon a cache miss, the `WANObjectCache::getWithSetCallback()` method generally +recomputes the value from the callback, and stores it for re-use. + +If regenerating the value costs more than a certain threshold of time (e.g. 50ms), +then for popular keys it is likely that many web servers will generate and store +the value simultaneously when the key is entirely absent from the cache. In this case, +the cool-off feature can be used to protect backend cache servers against network +congestion. This protection is implemented with a lock and subsequent cool-off period. +The winner stores their value, while other web server return their value directly. + +This counter is incremented whenever a new value was regenerated but not stored. + +* Type: Counter. +* Variable `kClass`: The first part of your cache key. + +When the regeneration callback is slow, these scenarios may use the cool-off feature: + +* Storing the first interim value for tombstoned keys. + + If a key is currently tombstoned due to a recent `delete()` action, and thus in "hold-off", then + the key may not be written to. A mutex lock will let one web server generate the new value and + (until the hold-off is over) the generated value will be considered an interim (temporary) value + only. Requests that cannot get the lock will use the last stored interim value. + If there is no interim value yet, then requests that cannot get the lock may still generate their + own value. Here, the cool-off feature is used to decide which requests stores their interim value. + +* Storing the first interim value for stale keys. + + If a key is currently in "hold-off" due to a recent `touchCheckKey()` action, then the key may + not be written to. A mutex lock will let one web request generate the new value and (until the + hold-off is over) such value will be considered an interim (temporary) value only. Requests that + lose the lock, will instead return the last stored interim value, or (if it remained in cache) the + stale value preserved from before `touchCheckKey()` was called. + If there is no stale value and no interim value yet, then multiple requests may need to + generate the value simultaneously. In this case, the cool-off feature is used to decide + which requests store their interim value. + + The same logic applies when the callback passed to getWithSetCallback() in the "touchedCallback" + parameter starts returning an updated timestamp due to a dependency change. + +* Storing the first value when `lockTSE` is used. + + When `lockTSE` is in use, and no stale value is found on the backend, and no `busyValue` + callback is provided, then multiple requests may generate the value simultaneously; + the cool-off is used to decide which requests store their interim value. diff --git a/includes/libs/objectcache/RESTBagOStuff.php b/includes/libs/objectcache/RESTBagOStuff.php index a10d1a47b2..aa4a9b31fc 100644 --- a/includes/libs/objectcache/RESTBagOStuff.php +++ b/includes/libs/objectcache/RESTBagOStuff.php @@ -44,7 +44,7 @@ use Psr\Log\LoggerInterface; * $wgSessionCacheType = 'sessions'; * @endcode */ -class RESTBagOStuff extends BagOStuff { +class RESTBagOStuff extends MediumSpecificBagOStuff { /** * Default connection timeout in seconds. The kernel retransmits the SYN * packet after 1 second, so 1.2 seconds allows for 1 retransmit without @@ -79,6 +79,7 @@ class RESTBagOStuff extends BagOStuff { private $extendedErrorBodyFields; public function __construct( $params ) { + $params['segmentationSize'] = $params['segmentationSize'] ?? INF; if ( empty( $params['url'] ) ) { throw new InvalidArgumentException( 'URL parameter is required' ); } @@ -146,7 +147,7 @@ class RESTBagOStuff extends BagOStuff { return false; } - public function set( $key, $value, $exptime = 0, $flags = 0 ) { + protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) { // @TODO: respect WRITE_SYNC (e.g. EACH_QUORUM) // @TODO: respect $exptime $req = [ @@ -172,7 +173,7 @@ class RESTBagOStuff extends BagOStuff { return false; // key already set } - public function delete( $key, $flags = 0 ) { + protected function doDelete( $key, $flags = 0 ) { // @TODO: respect WRITE_SYNC (e.g. EACH_QUORUM) $req = [ 'method' => 'DELETE', diff --git a/includes/libs/objectcache/RedisBagOStuff.php b/includes/libs/objectcache/RedisBagOStuff.php index 2c74d45916..87d26ef8fd 100644 --- a/includes/libs/objectcache/RedisBagOStuff.php +++ b/includes/libs/objectcache/RedisBagOStuff.php @@ -28,7 +28,7 @@ * @ingroup Cache * @ingroup Redis */ -class RedisBagOStuff extends BagOStuff { +class RedisBagOStuff extends MediumSpecificBagOStuff { /** @var RedisConnectionPool */ protected $redisPool; /** @var array List of server names */ @@ -89,10 +89,12 @@ class RedisBagOStuff extends BagOStuff { protected function doGet( $key, $flags = 0, &$casToken = null ) { $casToken = null; - list( $server, $conn ) = $this->getConnection( $key ); + $conn = $this->getConnection( $key ); if ( !$conn ) { return false; } + + $e = null; try { $value = $conn->get( $key ); $casToken = $value; @@ -102,21 +104,24 @@ class RedisBagOStuff extends BagOStuff { $this->handleException( $conn, $e ); } - $this->logRequest( 'get', $key, $server, $result ); + $this->logRequest( 'get', $key, $conn->getServer(), $e ); + return $result; } - public function set( $key, $value, $expiry = 0, $flags = 0 ) { - list( $server, $conn ) = $this->getConnection( $key ); + protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) { + $conn = $this->getConnection( $key ); if ( !$conn ) { return false; } - $expiry = $this->convertToRelative( $expiry ); + + $ttl = $this->convertToRelative( $exptime ); + + $e = null; try { - if ( $expiry ) { - $result = $conn->setex( $key, $expiry, $this->serialize( $value ) ); + if ( $ttl ) { + $result = $conn->setex( $key, $ttl, $this->serialize( $value ) ); } else { - // No expiry, that is very different from zero expiry in Redis $result = $conn->set( $key, $this->serialize( $value ) ); } } catch ( RedisException $e ) { @@ -124,52 +129,61 @@ class RedisBagOStuff extends BagOStuff { $this->handleException( $conn, $e ); } - $this->logRequest( 'set', $key, $server, $result ); + $this->logRequest( 'set', $key, $conn->getServer(), $e ); + return $result; } - public function delete( $key, $flags = 0 ) { - list( $server, $conn ) = $this->getConnection( $key ); + protected function doDelete( $key, $flags = 0 ) { + $conn = $this->getConnection( $key ); if ( !$conn ) { return false; } + + $e = null; try { - $conn->delete( $key ); - // Return true even if the key didn't exist - $result = true; + // Note that redis does not return false if the key was not there + $result = ( $conn->delete( $key ) !== false ); } catch ( RedisException $e ) { $result = false; $this->handleException( $conn, $e ); } - $this->logRequest( 'delete', $key, $server, $result ); + $this->logRequest( 'delete', $key, $conn->getServer(), $e ); + return $result; } - public function getMulti( array $keys, $flags = 0 ) { - $batches = []; + protected function doGetMulti( array $keys, $flags = 0 ) { + /** @var RedisConnRef[]|Redis[] $conns */ $conns = []; + $batches = []; foreach ( $keys as $key ) { - list( $server, $conn ) = $this->getConnection( $key ); - if ( !$conn ) { - continue; + $conn = $this->getConnection( $key ); + if ( $conn ) { + $server = $conn->getServer(); + $conns[$server] = $conn; + $batches[$server][] = $key; } - $conns[$server] = $conn; - $batches[$server][] = $key; } + $result = []; foreach ( $batches as $server => $batchKeys ) { $conn = $conns[$server]; + + $e = null; try { + // Avoid mget() to reduce CPU hogging from a single request $conn->multi( Redis::PIPELINE ); foreach ( $batchKeys as $key ) { $conn->get( $key ); } $batchResult = $conn->exec(); if ( $batchResult === false ) { - $this->debug( "multi request to $server failed" ); + $this->logRequest( 'get', implode( ',', $batchKeys ), $server, true ); continue; } + foreach ( $batchResult as $i => $value ) { if ( $value !== false ) { $result[$batchKeys[$i]] = $this->unserialize( $value ); @@ -178,138 +192,181 @@ class RedisBagOStuff extends BagOStuff { } catch ( RedisException $e ) { $this->handleException( $conn, $e ); } + + $this->logRequest( 'get', implode( ',', $batchKeys ), $server, $e ); } - $this->debug( "getMulti for " . count( $keys ) . " keys " . - "returned " . count( $result ) . " results" ); return $result; } - public function setMulti( array $data, $expiry = 0, $flags = 0 ) { - $batches = []; + protected function doSetMulti( array $data, $exptime = 0, $flags = 0 ) { + /** @var RedisConnRef[]|Redis[] $conns */ $conns = []; + $batches = []; foreach ( $data as $key => $value ) { - list( $server, $conn ) = $this->getConnection( $key ); - if ( !$conn ) { - continue; + $conn = $this->getConnection( $key ); + if ( $conn ) { + $server = $conn->getServer(); + $conns[$server] = $conn; + $batches[$server][] = $key; } - $conns[$server] = $conn; - $batches[$server][] = $key; } - $expiry = $this->convertToRelative( $expiry ); + $ttl = $this->convertToRelative( $exptime ); + $op = $ttl ? 'setex' : 'set'; + $result = true; foreach ( $batches as $server => $batchKeys ) { $conn = $conns[$server]; + + $e = null; try { + // Avoid mset() to reduce CPU hogging from a single request $conn->multi( Redis::PIPELINE ); foreach ( $batchKeys as $key ) { - if ( $expiry ) { - $conn->setex( $key, $expiry, $this->serialize( $data[$key] ) ); + if ( $ttl ) { + $conn->setex( $key, $ttl, $this->serialize( $data[$key] ) ); } else { $conn->set( $key, $this->serialize( $data[$key] ) ); } } $batchResult = $conn->exec(); if ( $batchResult === false ) { - $this->debug( "setMulti request to $server failed" ); + $this->logRequest( $op, implode( ',', $batchKeys ), $server, true ); continue; } - foreach ( $batchResult as $value ) { - if ( $value === false ) { - $result = false; - } - } + $result = $result && !in_array( false, $batchResult, true ); } catch ( RedisException $e ) { $this->handleException( $conn, $e ); $result = false; } + + $this->logRequest( $op, implode( ',', $batchKeys ), $server, $e ); } return $result; } - public function deleteMulti( array $keys, $flags = 0 ) { - $batches = []; + protected function doDeleteMulti( array $keys, $flags = 0 ) { + /** @var RedisConnRef[]|Redis[] $conns */ $conns = []; + $batches = []; foreach ( $keys as $key ) { - list( $server, $conn ) = $this->getConnection( $key ); - if ( !$conn ) { - continue; + $conn = $this->getConnection( $key ); + if ( $conn ) { + $server = $conn->getServer(); + $conns[$server] = $conn; + $batches[$server][] = $key; } - $conns[$server] = $conn; - $batches[$server][] = $key; } $result = true; foreach ( $batches as $server => $batchKeys ) { $conn = $conns[$server]; + + $e = null; try { + // Avoid delete() with array to reduce CPU hogging from a single request $conn->multi( Redis::PIPELINE ); foreach ( $batchKeys as $key ) { $conn->delete( $key ); } $batchResult = $conn->exec(); if ( $batchResult === false ) { - $this->debug( "deleteMulti request to $server failed" ); + $this->logRequest( 'delete', implode( ',', $batchKeys ), $server, true ); continue; } - foreach ( $batchResult as $value ) { - if ( $value === false ) { - $result = false; + // Note that redis does not return false if the key was not there + $result = $result && !in_array( false, $batchResult, true ); + } catch ( RedisException $e ) { + $this->handleException( $conn, $e ); + $result = false; + } + + $this->logRequest( 'delete', implode( ',', $batchKeys ), $server, $e ); + } + + return $result; + } + + public function changeTTLMulti( array $keys, $exptime, $flags = 0 ) { + /** @var RedisConnRef[]|Redis[] $conns */ + $conns = []; + $batches = []; + foreach ( $keys as $key ) { + $conn = $this->getConnection( $key ); + if ( $conn ) { + $server = $conn->getServer(); + $conns[$server] = $conn; + $batches[$server][] = $key; + } + } + + $relative = $this->expiryIsRelative( $exptime ); + $op = ( $exptime == 0 ) ? 'persist' : ( $relative ? 'expire' : 'expireAt' ); + + $result = true; + foreach ( $batches as $server => $batchKeys ) { + $conn = $conns[$server]; + + $e = null; + try { + $conn->multi( Redis::PIPELINE ); + foreach ( $batchKeys as $key ) { + if ( $exptime == 0 ) { + $conn->persist( $key ); + } elseif ( $relative ) { + $conn->expire( $key, $this->convertToRelative( $exptime ) ); + } else { + $conn->expireAt( $key, $this->convertToExpiry( $exptime ) ); } } + $batchResult = $conn->exec(); + if ( $batchResult === false ) { + $this->logRequest( $op, implode( ',', $batchKeys ), $server, true ); + continue; + } + $result = in_array( false, $batchResult, true ) ? false : $result; } catch ( RedisException $e ) { $this->handleException( $conn, $e ); $result = false; } + + $this->logRequest( $op, implode( ',', $batchKeys ), $server, $e ); } return $result; } public function add( $key, $value, $expiry = 0, $flags = 0 ) { - list( $server, $conn ) = $this->getConnection( $key ); + $conn = $this->getConnection( $key ); if ( !$conn ) { return false; } - $expiry = $this->convertToRelative( $expiry ); + + $ttl = $this->convertToRelative( $expiry ); try { - if ( $expiry ) { - $result = $conn->set( - $key, - $this->serialize( $value ), - [ 'nx', 'ex' => $expiry ] - ); - } else { - $result = $conn->setnx( $key, $this->serialize( $value ) ); - } + $result = $conn->set( + $key, + $this->serialize( $value ), + $ttl ? [ 'nx', 'ex' => $ttl ] : [ 'nx' ] + ); } catch ( RedisException $e ) { $result = false; $this->handleException( $conn, $e ); } - $this->logRequest( 'add', $key, $server, $result ); + $this->logRequest( 'add', $key, $conn->getServer(), $result ); + return $result; } - /** - * Non-atomic implementation of incr(). - * - * Probably all callers actually want incr() to atomically initialise - * values to zero if they don't exist, as provided by the Redis INCR - * command. But we are constrained by the memcached-like interface to - * return null in that case. Once the key exists, further increments are - * atomic. - * @param string $key Key to increase - * @param int $value Value to add to $key (Default 1) - * @return int|bool New value or false on failure - */ public function incr( $key, $value = 1 ) { - list( $server, $conn ) = $this->getConnection( $key ); + $conn = $this->getConnection( $key ); if ( !$conn ) { return false; } + try { if ( !$conn->exists( $key ) ) { return false; @@ -321,12 +378,13 @@ class RedisBagOStuff extends BagOStuff { $this->handleException( $conn, $e ); } - $this->logRequest( 'incr', $key, $server, $result ); + $this->logRequest( 'incr', $key, $conn->getServer(), $result ); + return $result; } - public function changeTTL( $key, $exptime = 0, $flags = 0 ) { - list( $server, $conn ) = $this->getConnection( $key ); + protected function doChangeTTL( $key, $exptime, $flags ) { + $conn = $this->getConnection( $key ); if ( !$conn ) { return false; } @@ -335,13 +393,13 @@ class RedisBagOStuff extends BagOStuff { try { if ( $exptime == 0 ) { $result = $conn->persist( $key ); - $this->logRequest( 'persist', $key, $server, $result ); + $this->logRequest( 'persist', $key, $conn->getServer(), $result ); } elseif ( $relative ) { $result = $conn->expire( $key, $this->convertToRelative( $exptime ) ); - $this->logRequest( 'expire', $key, $server, $result ); + $this->logRequest( 'expire', $key, $conn->getServer(), $result ); } else { $result = $conn->expireAt( $key, $this->convertToExpiry( $exptime ) ); - $this->logRequest( 'expireAt', $key, $server, $result ); + $this->logRequest( 'expireAt', $key, $conn->getServer(), $result ); } } catch ( RedisException $e ) { $result = false; @@ -352,28 +410,8 @@ class RedisBagOStuff extends BagOStuff { } /** - * @param mixed $data - * @return string - */ - protected function serialize( $data ) { - // Serialize anything but integers so INCR/DECR work - // Do not store integer-like strings as integers to avoid type confusion (T62563) - return is_int( $data ) ? $data : serialize( $data ); - } - - /** - * @param string $data - * @return mixed - */ - protected function unserialize( $data ) { - $int = intval( $data ); - return $data === (string)$int ? $int : unserialize( $data ); - } - - /** - * Get a Redis object with a connection suitable for fetching the specified key * @param string $key - * @return array (server, RedisConnRef) or (false, false) + * @return RedisConnRef|Redis|null Redis handle wrapper for the key or null on failure */ protected function getConnection( $key ) { $candidates = array_keys( $this->serverTagMap ); @@ -399,7 +437,9 @@ class RedisBagOStuff extends BagOStuff { // by now in such cases. if ( $this->automaticFailover && $candidates ) { try { - if ( $this->getMasterLinkStatus( $conn ) === 'down' ) { + /** @var string[] $info */ + $info = $conn->info(); + if ( ( $info['master_link_status'] ?? null ) === 'down' ) { // If the master cannot be reached, fail-over to the next server. // If masters are in data-center A, and replica DBs in data-center B, // this helps avoid the case were fail-over happens in A but not @@ -408,28 +448,17 @@ class RedisBagOStuff extends BagOStuff { } } catch ( RedisException $e ) { // Server is not accepting commands - $this->handleException( $conn, $e ); + $this->redisPool->handleError( $conn, $e ); continue; } } - return [ $server, $conn ]; + return $conn; } $this->setLastError( BagOStuff::ERR_UNREACHABLE ); - return [ false, false ]; - } - - /** - * Check the master link status of a Redis server that is configured as a replica DB. - * @param RedisConnRef $conn - * @return string|null Master link status (either 'up' or 'down'), or null - * if the server is not a replica DB. - */ - protected function getMasterLinkStatus( RedisConnRef $conn ) { - $info = $conn->info(); - return $info['master_link_status'] ?? null; + return null; } /** @@ -446,22 +475,21 @@ class RedisBagOStuff extends BagOStuff { * not. The safest response for us is to explicitly destroy the connection * object and let it be reopened during the next request. * @param RedisConnRef $conn - * @param Exception $e + * @param RedisException $e */ - protected function handleException( RedisConnRef $conn, $e ) { + protected function handleException( RedisConnRef $conn, RedisException $e ) { $this->setLastError( BagOStuff::ERR_UNEXPECTED ); $this->redisPool->handleError( $conn, $e ); } /** * Send information about a single request to the debug log - * @param string $method - * @param string $key + * @param string $op + * @param string $keys * @param string $server - * @param bool $result + * @param Exception|bool|null $e */ - public function logRequest( $method, $key, $server, $result ) { - $this->debug( "$method $key on $server: " . - ( $result === false ? "failure" : "success" ) ); + public function logRequest( $op, $keys, $server, $e = null ) { + $this->debug( "$op($keys) on $server: " . ( $e ? "failure" : "success" ) ); } } diff --git a/includes/libs/objectcache/ReplicatedBagOStuff.php b/includes/libs/objectcache/ReplicatedBagOStuff.php index 70f9096001..504d51534e 100644 --- a/includes/libs/objectcache/ReplicatedBagOStuff.php +++ b/includes/libs/objectcache/ReplicatedBagOStuff.php @@ -69,13 +69,14 @@ class ReplicatedBagOStuff extends BagOStuff { $this->attrMap = $this->mergeFlagMaps( [ $this->readStore, $this->writeStore ] ); } - public function setDebug( $debug ) { - $this->writeStore->setDebug( $debug ); - $this->readStore->setDebug( $debug ); + public function setDebug( $enabled ) { + parent::setDebug( $enabled ); + $this->writeStore->setDebug( $enabled ); + $this->readStore->setDebug( $enabled ); } public function get( $key, $flags = 0 ) { - return ( $flags & self::READ_LATEST ) + return ( ( $flags & self::READ_LATEST ) == self::READ_LATEST ) ? $this->writeStore->get( $key, $flags ) : $this->readStore->get( $key, $flags ); } @@ -108,8 +109,12 @@ class ReplicatedBagOStuff extends BagOStuff { return $this->writeStore->unlock( $key ); } - public function deleteObjectsExpiringBefore( $date, $progressCallback = false ) { - return $this->writeStore->deleteObjectsExpiringBefore( $date, $progressCallback ); + public function deleteObjectsExpiringBefore( + $timestamp, + callable $progress = null, + $limit = INF + ) { + return $this->writeStore->deleteObjectsExpiringBefore( $timestamp, $progress, $limit ); } public function getMulti( array $keys, $flags = 0 ) { @@ -126,6 +131,10 @@ class ReplicatedBagOStuff extends BagOStuff { return $this->writeStore->deleteMulti( $keys, $flags ); } + public function changeTTLMulti( array $keys, $exptime, $flags = 0 ) { + return $this->writeStore->changeTTLMulti( $keys, $exptime, $flags ); + } + public function incr( $key, $value = 1 ) { return $this->writeStore->incr( $key, $value ); } @@ -153,15 +162,21 @@ class ReplicatedBagOStuff extends BagOStuff { return $this->writeStore->makeKeyInternal( ...func_get_args() ); } - public function makeKey( $class, $component = null ) { + public function makeKey( $class, ...$components ) { return $this->writeStore->makeKey( ...func_get_args() ); } - public function makeGlobalKey( $class, $component = null ) { + public function makeGlobalKey( $class, ...$components ) { return $this->writeStore->makeGlobalKey( ...func_get_args() ); } - protected function doGet( $key, $flags = 0, &$casToken = null ) { - throw new LogicException( __METHOD__ . ': proxy class does not need this method.' ); + public function addBusyCallback( callable $workCallback ) { + $this->writeStore->addBusyCallback( $workCallback ); + } + + public function setMockTime( &$time ) { + parent::setMockTime( $time ); + $this->writeStore->setMockTime( $time ); + $this->readStore->setMockTime( $time ); } } diff --git a/includes/libs/objectcache/WANObjectCache.php b/includes/libs/objectcache/WANObjectCache.php index dac3421786..69edb11d81 100644 --- a/includes/libs/objectcache/WANObjectCache.php +++ b/includes/libs/objectcache/WANObjectCache.php @@ -113,27 +113,30 @@ use Psr\Log\NullLogger; * @ingroup Cache * @since 1.26 */ -class WANObjectCache implements IExpiringStore, LoggerAwareInterface { +class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInterface { /** @var BagOStuff The local datacenter cache */ protected $cache; /** @var MapCacheLRU[] Map of group PHP instance caches */ protected $processCaches = []; + /** @var LoggerInterface */ + protected $logger; + /** @var StatsdDataFactoryInterface */ + protected $stats; + /** @var callable|null Function that takes a WAN cache callback and runs it later */ + protected $asyncHandler; + /** @bar bool Whether to use mcrouter key prefixing for routing */ protected $mcrouterAware; /** @var string Physical region for mcrouter use */ protected $region; /** @var string Cache cluster name for mcrouter use */ protected $cluster; - /** @var LoggerInterface */ - protected $logger; - /** @var StatsdDataFactoryInterface */ - protected $stats; /** @var bool Whether to use "interim" caching while keys are tombstoned */ protected $useInterimHoldOffCaching = true; - /** @var callable|null Function that takes a WAN cache callback and runs it later */ - protected $asyncHandler; /** @var float Unix timestamp of the oldest possible valid values */ protected $epoch; + /** @var string Stable secret used for hasing long strings into key components */ + protected $secret; /** @var int Callback stack depth for getWithSetCallback() */ private $callbackDepth = 0; @@ -145,87 +148,104 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { /** @var float|null */ private $wallClockOverride; - /** Max time expected to pass between delete() and DB commit finishing */ + /** @var int Max expected seconds to pass between delete() and DB commit finishing */ const MAX_COMMIT_DELAY = 3; - /** Max replication+snapshot lag before applying TTL_LAGGED or disallowing set() */ + /** @var int Max expected seconds of combined lag from replication and view snapshots */ const MAX_READ_LAG = 7; - /** Seconds to tombstone keys on delete() */ - const HOLDOFF_TTL = 11; // MAX_COMMIT_DELAY + MAX_READ_LAG + 1 - - /** Seconds to keep dependency purge keys around */ - const CHECK_KEY_TTL = self::TTL_YEAR; - /** Seconds to keep interim value keys for tombstoned keys around */ - const INTERIM_KEY_TTL = 1; - - /** Seconds to keep lock keys around */ - const LOCK_TTL = 10; - /** Seconds to no-op key set() calls to avoid large blob I/O stampedes */ - const COOLOFF_TTL = 1; - /** Default remaining TTL at which to consider pre-emptive regeneration */ + /** @var int Seconds to tombstone keys on delete() and treat as volatile after invalidation */ + const HOLDOFF_TTL = self::MAX_COMMIT_DELAY + self::MAX_READ_LAG + 1; + + /** @var int Idiom for getWithSetCallback() meaning "do not store the callback result" */ + const TTL_UNCACHEABLE = -1; + + /** @var int Consider regeneration if the key will expire within this many seconds */ const LOW_TTL = 30; + /** @var int Max TTL, in seconds, to store keys when a data sourced is lagged */ + const TTL_LAGGED = 30; - /** Never consider performing "popularity" refreshes until a key reaches this age */ - const AGE_NEW = 60; - /** The time length of the "popularity" refresh window for hot keys */ + /** @var int Expected time-till-refresh, in seconds, if the key is accessed once per second */ const HOT_TTR = 900; - /** Hits/second for a refresh to be expected within the "popularity" window */ - const HIT_RATE_HIGH = 1; - /** Seconds to ramp up to the "popularity" refresh chance after a key is no longer new */ - const RAMPUP_TTL = 30; + /** @var int Minimum key age, in seconds, for expected time-till-refresh to be considered */ + const AGE_NEW = 60; - /** Idiom for getWithSetCallback() callbacks to avoid calling set() */ - const TTL_UNCACHEABLE = -1; - /** Idiom for getWithSetCallback() callbacks to 'lockTSE' logic */ + /** @var int Idiom for getWithSetCallback() meaning "no cache stampede mutex required" */ const TSE_NONE = -1; - /** Max TTL to store keys when a data sourced is lagged */ - const TTL_LAGGED = 30; - /** Idiom for delete() for "no hold-off" */ - const HOLDOFF_NONE = 0; - /** Idiom for set()/getWithSetCallback() for "do not augment the storage medium TTL" */ + + /** @var int Idiom for set()/getWithSetCallback() meaning "no post-expiration persistence" */ const STALE_TTL_NONE = 0; - /** Idiom for set()/getWithSetCallback() for "no post-expired grace period" */ + /** @var int Idiom for set()/getWithSetCallback() meaning "no post-expiration grace period" */ const GRACE_TTL_NONE = 0; + /** @var int Idiom for delete()/touchCheckKey() meaning "no hold-off period" */ + const HOLDOFF_TTL_NONE = 0; + /** @var int Alias for HOLDOFF_TTL_NONE (b/c) (deprecated since 1.34) */ + const HOLDOFF_NONE = self::HOLDOFF_TTL_NONE; - /** Idiom for getWithSetCallback() for "no minimum required as-of timestamp" */ + /** @var float Idiom for getWithSetCallback() meaning "no minimum required as-of timestamp" */ const MIN_TIMESTAMP_NONE = 0.0; - /** Tiny negative float to use when CTL comes up >= 0 due to clock skew */ - const TINY_NEGATIVE = -0.000001; - /** Tiny positive float to use when using "minTime" to assert an inequality */ - const TINY_POSTIVE = 0.000001; - - /** Milliseconds of delay after get() where set() storms are a consideration with 'lockTSE' */ - const SET_DELAY_HIGH_MS = 50; - /** Min millisecond set() backoff for keys in hold-off (far less than INTERIM_KEY_TTL) */ - const RECENT_SET_LOW_MS = 50; - /** Max millisecond set() backoff for keys in hold-off (far less than INTERIM_KEY_TTL) */ - const RECENT_SET_HIGH_MS = 100; + /** @var string Default process cache name and max key count */ + const PC_PRIMARY = 'primary:1000'; - /** Parameter to get()/getMulti() to return extra information by reference */ + /** @var int Idion for get()/getMulti() to return extra information by reference */ const PASS_BY_REF = -1; - /** Cache format version number */ - const VERSION = 1; - - const FLD_VERSION = 0; // key to cache version number - const FLD_VALUE = 1; // key to the cached value - const FLD_TTL = 2; // key to the original TTL - const FLD_TIME = 3; // key to the cache time - const FLD_FLAGS = 4; // key to the flags bitfield (reserved number) - const FLD_HOLDOFF = 5; // key to any hold-off TTL - - const VALUE_KEY_PREFIX = 'WANCache:v:'; - const INTERIM_KEY_PREFIX = 'WANCache:i:'; - const TIME_KEY_PREFIX = 'WANCache:t:'; - const MUTEX_KEY_PREFIX = 'WANCache:m:'; - const COOLOFF_KEY_PREFIX = 'WANCache:c:'; - - const PURGE_VAL_PREFIX = 'PURGED:'; - - const VFLD_DATA = 'WOC:d'; // key to the value of versioned data - const VFLD_VERSION = 'WOC:v'; // key to the version of the value present - - const PC_PRIMARY = 'primary:1000'; // process cache name and max key count + /** @var int Seconds to keep dependency purge keys around */ + private static $CHECK_KEY_TTL = self::TTL_YEAR; + /** @var int Seconds to keep interim value keys for tombstoned keys around */ + private static $INTERIM_KEY_TTL = 1; + + /** @var int Seconds to keep lock keys around */ + private static $LOCK_TTL = 10; + /** @var int Seconds to no-op key set() calls to avoid large blob I/O stampedes */ + private static $COOLOFF_TTL = 1; + /** @var int Seconds to ramp up the chance of regeneration due to expected time-till-refresh */ + private static $RAMPUP_TTL = 30; + + /** @var float Tiny negative float to use when CTL comes up >= 0 due to clock skew */ + private static $TINY_NEGATIVE = -0.000001; + /** @var float Tiny positive float to use when using "minTime" to assert an inequality */ + private static $TINY_POSTIVE = 0.000001; + + /** @var int Milliseconds of key fetch/validate/regenerate delay prone to set() stampedes */ + private static $SET_DELAY_HIGH_MS = 50; + /** @var int Min millisecond set() backoff during hold-off (far less than INTERIM_KEY_TTL) */ + private static $RECENT_SET_LOW_MS = 50; + /** @var int Max millisecond set() backoff during hold-off (far less than INTERIM_KEY_TTL) */ + private static $RECENT_SET_HIGH_MS = 100; + + /** @var int Consider value generation slow if it takes more than this many seconds */ + private static $GENERATION_SLOW_SEC = 3; + + /** @var int Key to the tombstone entry timestamp */ + private static $PURGE_TIME = 0; + /** @var int Key to the tombstone entry hold-off TTL */ + private static $PURGE_HOLDOFF = 1; + + /** @var int Cache format version number */ + private static $VERSION = 1; + + /** @var int Key to WAN cache version number */ + private static $FLD_FORMAT_VERSION = 0; + /** @var int Key to the cached value */ + private static $FLD_VALUE = 1; + /** @var int Key to the original TTL */ + private static $FLD_TTL = 2; + /** @var int Key to the cache timestamp */ + private static $FLD_TIME = 3; + /** @var int Key to the flags bit field (reserved number) */ + private static /** @noinspection PhpUnusedPrivateFieldInspection */ $FLD_FLAGS = 4; + /** @var int Key to collection cache version number */ + private static $FLD_VALUE_VERSION = 5; + /** @var int Key to how long it took to generate the value */ + private static $FLD_GENERATION_TIME = 6; + + private static $VALUE_KEY_PREFIX = 'WANCache:v:'; + private static $INTERIM_KEY_PREFIX = 'WANCache:i:'; + private static $TIME_KEY_PREFIX = 'WANCache:t:'; + private static $MUTEX_KEY_PREFIX = 'WANCache:m:'; + private static $COOLOFF_KEY_PREFIX = 'WANCache:c:'; + + private static $PURGE_VAL_PREFIX = 'PURGED:'; /** * @param array $params @@ -250,13 +270,15 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { * is configured to interpret /// key prefixes as routes. This * requires that "region" and "cluster" are both set above. [optional] * - epoch: lowest UNIX timestamp a value/tombstone must have to be valid. [optional] + * - secret: stable secret used for hashing long strings into key components. [optional] */ public function __construct( array $params ) { $this->cache = $params['cache']; $this->region = $params['region'] ?? 'main'; $this->cluster = $params['cluster'] ?? 'wan-main'; $this->mcrouterAware = !empty( $params['mcrouterAware'] ); - $this->epoch = $params['epoch'] ?? 1.0; + $this->epoch = $params['epoch'] ?? 0; + $this->secret = $params['secret'] ?? (string)$this->epoch; $this->setLogger( $params['logger'] ?? new NullLogger() ); $this->stats = $params['stats'] ?? new NullStatsdDataFactory(); @@ -314,17 +336,18 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { * Consider using getWithSetCallback() instead of get() and set() cycles. * That method has cache slam avoiding features for hot/expensive keys. * - * Pass $info as WANObjectCache::PASS_BY_REF to transform it into a cache key info map. + * Pass $info as WANObjectCache::PASS_BY_REF to transform it into a cache key metadata map. * This map includes the following metadata: * - asOf: UNIX timestamp of the value or null if the key is nonexistant * - tombAsOf: UNIX timestamp of the tombstone or null if the key is not tombstoned * - lastCKPurge: UNIX timestamp of the highest check key or null if none provided + * - version: cached value version number or null if the key is nonexistant * * Otherwise, $info will transform into the cached value timestamp. * * @param string $key Cache key made from makeKey() or makeGlobalKey() * @param mixed|null &$curTTL Approximate TTL left on the key if present/tombstoned [returned] - * @param array $checkKeys List of "check" keys + * @param string[] $checkKeys The "check" keys used to validate the value * @param mixed|null &$info Key info if WANObjectCache::PASS_BY_REF [returned] * @return mixed Value of cache key or false on failure */ @@ -339,7 +362,8 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { $info = [ 'asOf' => $infoByKey[$key]['asOf'] ?? null, 'tombAsOf' => $infoByKey[$key]['tombAsOf'] ?? null, - 'lastCKPurge' => $infoByKey[$key]['lastCKPurge'] ?? null + 'lastCKPurge' => $infoByKey[$key]['lastCKPurge'] ?? null, + 'version' => $infoByKey[$key]['version'] ?? null ]; } else { $info = $infoByKey[$key]['asOf'] ?? null; // b/c @@ -352,20 +376,23 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { * Fetch the value of several keys from cache * * Pass $info as WANObjectCache::PASS_BY_REF to transform it into a map of cache keys - * to cache key info maps, each having the same style as those of WANObjectCache::get(). + * to cache key metadata maps, each having the same style as those of WANObjectCache::get(). * All the cache keys listed in $keys will have an entry. * * Othwerwise, $info will transform into a map of (cache key => cached value timestamp). * Only the cache keys listed in $keys that exists or are tombstoned will have an entry. * + * $checkKeys holds the "check" keys used to validate values of applicable keys. The integer + * indexes hold "check" keys that apply to all of $keys while the string indexes hold "check" + * keys that only apply to the cache key with that name. + * * @see WANObjectCache::get() * - * @param array $keys List of cache keys made from makeKey() or makeGlobalKey() + * @param string[] $keys List of cache keys made from makeKey() or makeGlobalKey() * @param mixed|null &$curTTLs Map of (key => TTL left) for existing/tombstoned keys [returned] - * @param array $checkKeys List of check keys to apply to all $keys. May also apply "check" - * keys to specific cache keys only by using cache keys as keys in the $checkKeys array. + * @param string[]|string[][] $checkKeys Map of (integer or cache key => "check" key(s)) * @param mixed|null &$info Map of (key => info) if WANObjectCache::PASS_BY_REF [returned] - * @return array Map of (key => value) for keys that exist and are not tombstoned + * @return mixed[] Map of (key => value) for existing values; order of $keys is preserved */ final public function getMulti( array $keys, @@ -377,14 +404,14 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { $curTTLs = []; $infoByKey = []; - $vPrefixLen = strlen( self::VALUE_KEY_PREFIX ); - $valueKeys = self::prefixCacheKeys( $keys, self::VALUE_KEY_PREFIX ); + $vPrefixLen = strlen( self::$VALUE_KEY_PREFIX ); + $valueKeys = self::prefixCacheKeys( $keys, self::$VALUE_KEY_PREFIX ); $checkKeysForAll = []; $checkKeysByKey = []; $checkKeysFlat = []; foreach ( $checkKeys as $i => $checkKeyGroup ) { - $prefixed = self::prefixCacheKeys( (array)$checkKeyGroup, self::TIME_KEY_PREFIX ); + $prefixed = self::prefixCacheKeys( (array)$checkKeyGroup, self::$TIME_KEY_PREFIX ); $checkKeysFlat = array_merge( $checkKeysFlat, $prefixed ); // Are these check keys for a specific cache key, or for all keys being fetched? if ( is_int( $i ) ) { @@ -420,9 +447,7 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { // Get the main cache value for each key and validate them foreach ( $valueKeys as $vKey ) { $key = substr( $vKey, $vPrefixLen ); // unprefix - list( $value, $curTTL, $asOf, $tombAsOf ) = isset( $wrappedValues[$vKey] ) - ? $this->unwrap( $wrappedValues[$vKey], $now ) - : [ false, null, null, null ]; // not found + list( $value, $keyInfo ) = $this->unwrap( $wrappedValues[$vKey] ?? false, $now ); // Force dependent keys to be seen as stale for a while after purging // to reduce race conditions involving stale data getting cached $purgeValues = $purgeValuesForAll; @@ -432,26 +457,27 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { $lastCKPurge = null; // timestamp of the highest check key foreach ( $purgeValues as $purge ) { - $lastCKPurge = max( $purge[self::FLD_TIME], $lastCKPurge ); - $safeTimestamp = $purge[self::FLD_TIME] + $purge[self::FLD_HOLDOFF]; - if ( $value !== false && $safeTimestamp >= $asOf ) { + $lastCKPurge = max( $purge[self::$PURGE_TIME], $lastCKPurge ); + $safeTimestamp = $purge[self::$PURGE_TIME] + $purge[self::$PURGE_HOLDOFF]; + if ( $value !== false && $safeTimestamp >= $keyInfo['asOf'] ) { // How long ago this value was invalidated by *this* check key - $ago = min( $purge[self::FLD_TIME] - $now, self::TINY_NEGATIVE ); + $ago = min( $purge[self::$PURGE_TIME] - $now, self::$TINY_NEGATIVE ); // How long ago this value was invalidated by *any* known check key - $curTTL = min( $curTTL, $ago ); + $keyInfo['curTTL'] = min( $keyInfo['curTTL'], $ago ); } } + $keyInfo[ 'lastCKPurge'] = $lastCKPurge; if ( $value !== false ) { $result[$key] = $value; } - if ( $curTTL !== null ) { - $curTTLs[$key] = $curTTL; + if ( $keyInfo['curTTL'] !== null ) { + $curTTLs[$key] = $keyInfo['curTTL']; } $infoByKey[$key] = ( $info === self::PASS_BY_REF ) - ? [ 'asOf' => $asOf, 'tombAsOf' => $tombAsOf, 'lastCKPurge' => $lastCKPurge ] - : $asOf; // b/c + ? $keyInfo + : $keyInfo['asOf']; // b/c } $info = $infoByKey; @@ -461,10 +487,10 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { /** * @since 1.27 - * @param array $timeKeys List of prefixed time check keys - * @param array $wrappedValues + * @param string[] $timeKeys List of prefixed time check keys + * @param mixed[] $wrappedValues * @param float $now - * @return array List of purge value arrays + * @return array[] List of purge value arrays */ private function processCheckKeys( array $timeKeys, array $wrappedValues, $now ) { $purgeValues = []; @@ -475,7 +501,7 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { if ( $purge === false ) { // Key is not set or malformed; regenerate $newVal = $this->makePurgeValue( $now, self::HOLDOFF_TTL ); - $this->cache->add( $timeKey, $newVal, self::CHECK_KEY_TTL ); + $this->cache->add( $timeKey, $newVal, self::$CHECK_KEY_TTL ); $purge = $this->parsePurgeValue( $newVal ); } $purgeValues[] = $purge; @@ -520,8 +546,9 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { * @param mixed $value * @param int $ttl Seconds to live. Special values are: * - WANObjectCache::TTL_INDEFINITE: Cache forever (default) + * - WANObjectCache::TTL_UNCACHEABLE: Do not cache (if the key exists, it is not deleted) * @param array $opts Options map: - * - lag: seconds of replica DB lag. Typically, this is either the replica DB lag + * - lag: Seconds of replica DB lag. Typically, this is either the replica DB lag * before the data was read or, if applicable, the replica DB lag before * the snapshot-isolated transaction the data was read from started. * Use false to indicate that replication is not running. @@ -530,37 +557,48 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { * the current time the data was read or (if applicable) the time when * the snapshot-isolated transaction the data was read from started. * Default: 0 seconds - * - pending: whether this data is possibly from an uncommitted write transaction. + * - pending: Whether this data is possibly from an uncommitted write transaction. * Generally, other threads should not see values from the future and * they certainly should not see ones that ended up getting rolled back. * Default: false - * - lockTSE: if excessive replication/snapshot lag is detected, then store the value + * - lockTSE: If excessive replication/snapshot lag is detected, then store the value * with this TTL and flag it as stale. This is only useful if the reads for this key * use getWithSetCallback() with "lockTSE" set. Note that if "staleTTL" is set * then it will still add on to this TTL in the excessive lag scenario. * Default: WANObjectCache::TSE_NONE - * - staleTTL: seconds to keep the key around if it is stale. The get()/getMulti() + * - staleTTL: Seconds to keep the key around if it is stale. The get()/getMulti() * methods return such stale values with a $curTTL of 0, and getWithSetCallback() * will call the regeneration callback in such cases, passing in the old value * and its as-of time to the callback. This is useful if adaptiveTTL() is used * on the old value's as-of time when it is verified as still being correct. - * Default: WANObjectCache::STALE_TTL_NONE. - * - creating: optimize for the case where the key does not already exist. + * Default: WANObjectCache::STALE_TTL_NONE + * - creating: Optimize for the case where the key does not already exist. * Default: false + * - version: Integer version number signifiying the format of the value. + * Default: null + * - walltime: How long the value took to generate in seconds. Default: 0.0 * @note Options added in 1.28: staleTTL * @note Options added in 1.33: creating + * @note Options added in 1.34: version, walltime * @return bool Success */ final public function set( $key, $value, $ttl = self::TTL_INDEFINITE, array $opts = [] ) { $now = $this->getCurrentTime(); + $lag = $opts['lag'] ?? 0; + $age = isset( $opts['since'] ) ? max( 0, $now - $opts['since'] ) : 0; + $pending = $opts['pending'] ?? false; $lockTSE = $opts['lockTSE'] ?? self::TSE_NONE; $staleTTL = $opts['staleTTL'] ?? self::STALE_TTL_NONE; - $age = isset( $opts['since'] ) ? max( 0, $now - $opts['since'] ) : 0; $creating = $opts['creating'] ?? false; - $lag = $opts['lag'] ?? 0; + $version = $opts['version'] ?? null; + $walltime = $opts['walltime'] ?? 0.0; + + if ( $ttl < 0 ) { + return true; + } // Do not cache potentially uncommitted data as it might get rolled back - if ( !empty( $opts['pending'] ) ) { + if ( $pending ) { $this->logger->info( 'Rejected set() for {cachekey} due to pending writes.', [ 'cachekey' => $key ] @@ -619,14 +657,14 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { } // Wrap that value with time/TTL/version metadata - $wrapped = $this->wrap( $value, $logicalTTL ?: $ttl, $now ); + $wrapped = $this->wrap( $value, $logicalTTL ?: $ttl, $version, $now, $walltime ); $storeTTL = $ttl + $staleTTL; if ( $creating ) { - $ok = $this->cache->add( self::VALUE_KEY_PREFIX . $key, $wrapped, $storeTTL ); + $ok = $this->cache->add( self::$VALUE_KEY_PREFIX . $key, $wrapped, $storeTTL ); } else { $ok = $this->cache->merge( - self::VALUE_KEY_PREFIX . $key, + self::$VALUE_KEY_PREFIX . $key, function ( $cache, $key, $cWrapped ) use ( $wrapped ) { // A string value means that it is a tombstone; do nothing in that case return ( is_string( $cWrapped ) ) ? false : $wrapped; @@ -690,7 +728,7 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { * * The $ttl parameter can be used when purging values that have not actually changed * recently. For example, a cleanup script to purge cache entries does not really need - * a hold-off period, so it can use HOLDOFF_NONE. Likewise for user-requested purge. + * a hold-off period, so it can use HOLDOFF_TTL_NONE. Likewise for user-requested purge. * Note that $ttl limits the effective range of 'lockTSE' for getWithSetCallback(). * * If called twice on the same key, then the last hold-off TTL takes precedence. For @@ -703,10 +741,10 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { final public function delete( $key, $ttl = self::HOLDOFF_TTL ) { if ( $ttl <= 0 ) { // Publish the purge to all datacenters - $ok = $this->relayDelete( self::VALUE_KEY_PREFIX . $key ); + $ok = $this->relayDelete( self::$VALUE_KEY_PREFIX . $key ); } else { // Publish the purge to all datacenters - $ok = $this->relayPurge( self::VALUE_KEY_PREFIX . $key, $ttl, self::HOLDOFF_NONE ); + $ok = $this->relayPurge( self::$VALUE_KEY_PREFIX . $key, $ttl, self::HOLDOFF_TTL_NONE ); } $kClass = $this->determineKeyClassForStats( $key ); @@ -795,14 +833,14 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { * @see WANObjectCache::getCheckKeyTime() * @see WANObjectCache::getWithSetCallback() * - * @param array $keys + * @param string[] $keys * @return float[] Map of (key => UNIX timestamp) * @since 1.31 */ final public function getMultiCheckKeyTime( array $keys ) { $rawKeys = []; foreach ( $keys as $key ) { - $rawKeys[$key] = self::TIME_KEY_PREFIX . $key; + $rawKeys[$key] = self::$TIME_KEY_PREFIX . $key; } $rawValues = $this->cache->getMulti( $rawKeys ); @@ -812,14 +850,14 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { foreach ( $rawKeys as $key => $rawKey ) { $purge = $this->parsePurgeValue( $rawValues[$rawKey] ); if ( $purge !== false ) { - $time = $purge[self::FLD_TIME]; + $time = $purge[self::$PURGE_TIME]; } else { // Casting assures identical floats for the next getCheckKeyTime() calls $now = (string)$this->getCurrentTime(); $this->cache->add( $rawKey, $this->makePurgeValue( $now, self::HOLDOFF_TTL ), - self::CHECK_KEY_TTL + self::$CHECK_KEY_TTL ); $time = (float)$now; } @@ -861,12 +899,12 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { * @see WANObjectCache::resetCheckKey() * * @param string $key Cache key - * @param int $holdoff HOLDOFF_TTL or HOLDOFF_NONE constant + * @param int $holdoff HOLDOFF_TTL or HOLDOFF_TTL_NONE constant * @return bool True if the item was purged or not found, false on failure */ final public function touchCheckKey( $key, $holdoff = self::HOLDOFF_TTL ) { // Publish the purge to all datacenters - $ok = $this->relayPurge( self::TIME_KEY_PREFIX . $key, self::CHECK_KEY_TTL, $holdoff ); + $ok = $this->relayPurge( self::$TIME_KEY_PREFIX . $key, self::$CHECK_KEY_TTL, $holdoff ); $kClass = $this->determineKeyClassForStats( $key ); $this->stats->increment( "wanobjectcache.$kClass.ck_touch." . ( $ok ? 'ok' : 'error' ) ); @@ -903,7 +941,7 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { */ final public function resetCheckKey( $key ) { // Publish the purge to all datacenters - $ok = $this->relayDelete( self::TIME_KEY_PREFIX . $key ); + $ok = $this->relayDelete( self::$TIME_KEY_PREFIX . $key ); $kClass = $this->determineKeyClassForStats( $key ); $this->stats->increment( "wanobjectcache.$kClass.ck_reset." . ( $ok ? 'ok' : 'error' ) ); @@ -1213,72 +1251,39 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { * @note Callable type hints are not used to avoid class-autoloading */ final public function getWithSetCallback( $key, $ttl, $callback, array $opts = [] ) { + $version = $opts['version'] ?? null; $pcTTL = $opts['pcTTL'] ?? self::TTL_UNCACHEABLE; - - // Try the process cache if enabled and the cache callback is not within a cache callback. - // Process cache use in nested callbacks is not lag-safe with regard to HOLDOFF_TTL since - // the in-memory value is further lagged than the shared one since it uses a blind TTL. - if ( $pcTTL >= 0 && $this->callbackDepth == 0 ) { - $group = $opts['pcGroup'] ?? self::PC_PRIMARY; - $procCache = $this->getProcessCache( $group ); - $value = $procCache->has( $key, $pcTTL ) ? $procCache->get( $key ) : false; - } else { - $procCache = false; - $value = false; + $pCache = ( $pcTTL >= 0 ) + ? $this->getProcessCache( $opts['pcGroup'] ?? self::PC_PRIMARY ) + : null; + + // Use the process cache if requested as long as no outer cache callback is running. + // Nested callback process cache use is not lag-safe with regard to HOLDOFF_TTL since + // process cached values are more lagged than persistent ones as they are not purged. + if ( $pCache && $this->callbackDepth == 0 ) { + $cached = $pCache->get( $this->getProcessCacheKey( $key, $version ), INF, false ); + if ( $cached !== false ) { + return $cached; + } } - if ( $value === false ) { - // Fetch the value over the network - if ( isset( $opts['version'] ) ) { - $version = $opts['version']; - $asOf = null; - $cur = $this->doGetWithSetCallback( - $key, - $ttl, - function ( $oldValue, &$ttl, &$setOpts, $oldAsOf ) - use ( $callback, $version ) { - if ( is_array( $oldValue ) - && array_key_exists( self::VFLD_DATA, $oldValue ) - && array_key_exists( self::VFLD_VERSION, $oldValue ) - && $oldValue[self::VFLD_VERSION] === $version - ) { - $oldData = $oldValue[self::VFLD_DATA]; - } else { - // VFLD_DATA is not set if an old, unversioned, key is present - $oldData = false; - $oldAsOf = null; - } - - return [ - self::VFLD_DATA => $callback( $oldData, $ttl, $setOpts, $oldAsOf ), - self::VFLD_VERSION => $version - ]; - }, - $opts, - $asOf - ); - if ( $cur[self::VFLD_VERSION] === $version ) { - // Value created or existed before with version; use it - $value = $cur[self::VFLD_DATA]; - } else { - // Value existed before with a different version; use variant key. - // Reflect purges to $key by requiring that this key value be newer. - $value = $this->doGetWithSetCallback( - $this->makeGlobalKey( 'WANCache-key-variant', md5( $key ), $version ), - $ttl, - $callback, - // Regenerate value if not newer than $key - [ 'version' => null, 'minAsOf' => $asOf ] + $opts - ); - } - } else { - $value = $this->doGetWithSetCallback( $key, $ttl, $callback, $opts ); - } + $res = $this->fetchOrRegenerate( $key, $ttl, $callback, $opts ); + list( $value, $valueVersion, $curAsOf ) = $res; + if ( $valueVersion !== $version ) { + // Current value has a different version; use the variant key for this version. + // Regenerate the variant value if it is not newer than the main value at $key + // so that purges to the main key propagate to the variant value. + list( $value ) = $this->fetchOrRegenerate( + $this->makeGlobalKey( 'WANCache-key-variant', md5( $key ), $version ), + $ttl, + $callback, + [ 'version' => null, 'minAsOf' => $curAsOf ] + $opts + ); + } - // Update the process cache if enabled - if ( $procCache && $value !== false ) { - $procCache->set( $key, $value ); - } + // Update the process cache if enabled + if ( $pCache && $value !== false ) { + $pCache->set( $this->getProcessCacheKey( $key, $version ), $value ); } return $value; @@ -1293,78 +1298,80 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { * @param int $ttl * @param callable $callback * @param array $opts Options map for getWithSetCallback() - * @param float|null &$asOf Cache generation timestamp of returned value [returned] - * @return mixed + * @return array Ordered list of the following: + * - Cached or regenerated value + * - Cached or regenerated value version number or null if not versioned + * - Timestamp of the cached value or null if there is no value * @note Callable type hints are not used to avoid class-autoloading */ - protected function doGetWithSetCallback( $key, $ttl, $callback, array $opts, &$asOf = null ) { - $lowTTL = $opts['lowTTL'] ?? min( self::LOW_TTL, $ttl ); - $lockTSE = $opts['lockTSE'] ?? self::TSE_NONE; - $staleTTL = $opts['staleTTL'] ?? self::STALE_TTL_NONE; - $graceTTL = $opts['graceTTL'] ?? self::GRACE_TTL_NONE; + private function fetchOrRegenerate( $key, $ttl, $callback, array $opts ) { $checkKeys = $opts['checkKeys'] ?? []; - $busyValue = $opts['busyValue'] ?? null; - $popWindow = $opts['hotTTR'] ?? self::HOT_TTR; + $graceTTL = $opts['graceTTL'] ?? self::GRACE_TTL_NONE; + $minAsOf = $opts['minAsOf'] ?? self::MIN_TIMESTAMP_NONE; + $hotTTR = $opts['hotTTR'] ?? self::HOT_TTR; + $lowTTL = $opts['lowTTL'] ?? min( self::LOW_TTL, $ttl ); $ageNew = $opts['ageNew'] ?? self::AGE_NEW; - $minTime = $opts['minAsOf'] ?? self::MIN_TIMESTAMP_NONE; - $needsVersion = isset( $opts['version'] ); $touchedCb = $opts['touchedCallback'] ?? null; $initialTime = $this->getCurrentTime(); $kClass = $this->determineKeyClassForStats( $key ); - // Get the current key value + // Get the current key value and its metadata $curTTL = self::PASS_BY_REF; $curInfo = self::PASS_BY_REF; /** @var array $curInfo */ $curValue = $this->get( $key, $curTTL, $checkKeys, $curInfo ); // Apply any $touchedCb invalidation timestamp to get the "last purge timestamp" list( $curTTL, $LPT ) = $this->resolveCTL( $curValue, $curTTL, $curInfo, $touchedCb ); - // Keep track of the best candidate value and its timestamp - $value = $curValue; // return value - $asOf = $curInfo['asOf']; // return value timestamp - - // Determine if a cached value regeneration is needed or desired + // Use the cached value if it exists and is not due for synchronous regeneration if ( - $this->isValid( $value, $needsVersion, $asOf, $minTime ) && + $this->isValid( $curValue, $curInfo['asOf'], $minAsOf ) && $this->isAliveOrInGracePeriod( $curTTL, $graceTTL ) ) { $preemptiveRefresh = ( $this->worthRefreshExpiring( $curTTL, $lowTTL ) || - $this->worthRefreshPopular( $asOf, $ageNew, $popWindow, $initialTime ) + $this->worthRefreshPopular( $curInfo['asOf'], $ageNew, $hotTTR, $initialTime ) ); - if ( !$preemptiveRefresh ) { $this->stats->increment( "wanobjectcache.$kClass.hit.good" ); - return $value; + return [ $curValue, $curInfo['version'], $curInfo['asOf'] ]; } elseif ( $this->scheduleAsyncRefresh( $key, $ttl, $callback, $opts ) ) { $this->stats->increment( "wanobjectcache.$kClass.hit.refresh" ); - return $value; + return [ $curValue, $curInfo['version'], $curInfo['asOf'] ]; } } + // Determine if there is stale or volatile cached value that is still usable $isKeyTombstoned = ( $curInfo['tombAsOf'] !== null ); if ( $isKeyTombstoned ) { - // Get the interim key value since the key is tombstoned (write-holed) - list( $value, $asOf ) = $this->getInterimValue( $key, $needsVersion, $minTime ); + // Key is write-holed; use the (volatile) interim key as an alternative + list( $possValue, $possInfo ) = $this->getInterimValue( $key, $minAsOf ); // Update the "last purge time" since the $touchedCb timestamp depends on $value - $LPT = $this->resolveTouched( $value, $LPT, $touchedCb ); + $LPT = $this->resolveTouched( $possValue, $LPT, $touchedCb ); + } else { + $possValue = $curValue; + $possInfo = $curInfo; } - // Reduce mutex and cache set spam while keys are in the tombstone/holdoff period by - // checking if $value was genereated by a recent thread much less than a second ago. + // Avoid overhead from callback runs, regeneration locks, and cache sets during + // hold-off periods for the key by reusing very recently generated cached values if ( - $this->isValid( $value, $needsVersion, $asOf, $minTime, $LPT ) && - $this->isVolatileValueAgeNegligible( $initialTime - $asOf ) + $this->isValid( $possValue, $possInfo['asOf'], $minAsOf, $LPT ) && + $this->isVolatileValueAgeNegligible( $initialTime - $possInfo['asOf'] ) ) { $this->stats->increment( "wanobjectcache.$kClass.hit.volatile" ); - return $value; + return [ $possValue, $possInfo['version'], $curInfo['asOf'] ]; } - // Decide if only one thread should handle regeneration at a time - $useMutex = + $lockTSE = $opts['lockTSE'] ?? self::TSE_NONE; + $busyValue = $opts['busyValue'] ?? null; + $staleTTL = $opts['staleTTL'] ?? self::STALE_TTL_NONE; + $version = $opts['version'] ?? null; + + // Determine whether one thread per datacenter should handle regeneration at a time + $useRegenerationLock = // Note that since tombstones no-op set(), $lockTSE and $curTTL cannot be used to // deduce the key hotness because |$curTTL| will always keep increasing until the // tombstone expires or is overwritten by a new tombstone. Also, even if $lockTSE @@ -1377,78 +1384,104 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { ( $curTTL !== null && $curTTL <= 0 && abs( $curTTL ) <= $lockTSE ) || // Assume a key is hot if there is no value and a busy fallback is given. // This avoids stampedes on eviction or preemptive regeneration taking too long. - ( $busyValue !== null && $value === false ); - - $hasLock = false; - if ( $useMutex ) { - // Acquire a datacenter-local non-blocking lock - if ( $this->cache->add( self::MUTEX_KEY_PREFIX . $key, 1, self::LOCK_TTL ) ) { - // Lock acquired; this thread will recompute the value and update cache - $hasLock = true; - } elseif ( $this->isValid( $value, $needsVersion, $asOf, $minTime ) ) { - // Lock not acquired and a stale value exists; use the stale value + ( $busyValue !== null && $possValue === false ); + + // If a regeneration lock is required, threads that do not get the lock will try to use + // the stale value, the interim value, or the $busyValue placeholder, in that order. If + // none of those are set then all threads will bypass the lock and regenerate the value. + $hasLock = $useRegenerationLock && $this->claimStampedeLock( $key ); + if ( $useRegenerationLock && !$hasLock ) { + if ( $this->isValid( $possValue, $possInfo['asOf'], $minAsOf ) ) { $this->stats->increment( "wanobjectcache.$kClass.hit.stale" ); - return $value; - } else { - // Lock not acquired and no stale value exists - if ( $busyValue !== null ) { - // Use the busy fallback value if nothing else - $miss = is_infinite( $minTime ) ? 'renew' : 'miss'; - $this->stats->increment( "wanobjectcache.$kClass.$miss.busy" ); + return [ $possValue, $possInfo['version'], $curInfo['asOf'] ]; + } elseif ( $busyValue !== null ) { + $miss = is_infinite( $minAsOf ) ? 'renew' : 'miss'; + $this->stats->increment( "wanobjectcache.$kClass.$miss.busy" ); - return is_callable( $busyValue ) ? $busyValue() : $busyValue; - } + return [ + is_callable( $busyValue ) ? $busyValue() : $busyValue, + $version, + $curInfo['asOf'] + ]; } } - if ( !is_callable( $callback ) ) { - throw new InvalidArgumentException( "Invalid cache miss callback provided." ); - } - - $preCallbackTime = $this->getCurrentTime(); - // Generate the new value from the callback... + // Generate the new value given any prior value with a matching version $setOpts = []; + $preCallbackTime = $this->getCurrentTime(); ++$this->callbackDepth; try { - $value = call_user_func_array( $callback, [ $curValue, &$ttl, &$setOpts, $asOf ] ); + $value = $callback( + ( $curInfo['version'] === $version ) ? $curValue : false, + $ttl, + $setOpts, + ( $curInfo['version'] === $version ) ? $curInfo['asOf'] : null + ); } finally { --$this->callbackDepth; } - $valueIsCacheable = ( $value !== false && $ttl >= 0 ); + $postCallbackTime = $this->getCurrentTime(); - if ( $valueIsCacheable ) { - $ago = max( $this->getCurrentTime() - $initialTime, 0.0 ); - $this->stats->timing( "wanobjectcache.$kClass.regen_set_delay", 1000 * $ago ); + // How long it took to fetch, validate, and generate the value + $elapsed = max( $postCallbackTime - $initialTime, 0.0 ); + // Attempt to save the newly generated value if applicable + if ( + // Callback yielded a cacheable value + ( $value !== false && $ttl >= 0 ) && + // Current thread was not raced out of a regeneration lock or key is tombstoned + ( !$useRegenerationLock || $hasLock || $isKeyTombstoned ) && + // Key does not appear to be undergoing a set() stampede + $this->checkAndSetCooloff( $key, $kClass, $elapsed, $lockTSE, $hasLock ) + ) { + // How long it took to generate the value + $walltime = max( $postCallbackTime - $preCallbackTime, 0.0 ); + $this->stats->timing( "wanobjectcache.$kClass.regen_walltime", 1e3 * $walltime ); + // If the key is write-holed then use the (volatile) interim key as an alternative if ( $isKeyTombstoned ) { - if ( $this->checkAndSetCooloff( $key, $kClass, $ago, $lockTSE, $hasLock ) ) { - // Use the interim key value since the key is tombstoned (write-holed) - $tempTTL = max( self::INTERIM_KEY_TTL, (int)$lockTSE ); - $this->setInterimValue( $key, $value, $tempTTL, $this->getCurrentTime() ); - } - } elseif ( !$useMutex || $hasLock ) { - if ( $this->checkAndSetCooloff( $key, $kClass, $ago, $lockTSE, $hasLock ) ) { - $setOpts['creating'] = ( $curValue === false ); - // Save the value unless a lock-winning thread is already expected to do that - $setOpts['lockTSE'] = $lockTSE; - $setOpts['staleTTL'] = $staleTTL; - // Use best known "since" timestamp if not provided - $setOpts += [ 'since' => $preCallbackTime ]; - // Update the cache; this will fail if the key is tombstoned - $this->set( $key, $value, $ttl, $setOpts ); - } + $this->setInterimValue( $key, $value, $lockTSE, $version, $walltime ); + } else { + $finalSetOpts = [ + 'since' => $setOpts['since'] ?? $preCallbackTime, + 'version' => $version, + 'staleTTL' => $staleTTL, + 'lockTSE' => $lockTSE, // informs lag vs performance trade-offs + 'creating' => ( $curValue === false ), // optimization + 'walltime' => $walltime + ] + $setOpts; + $this->set( $key, $value, $ttl, $finalSetOpts ); } } - if ( $hasLock ) { - $this->cache->changeTTL( self::MUTEX_KEY_PREFIX . $key, (int)$initialTime - 60 ); - } + $this->yieldStampedeLock( $key, $hasLock ); - $miss = is_infinite( $minTime ) ? 'renew' : 'miss'; + $miss = is_infinite( $minAsOf ) ? 'renew' : 'miss'; $this->stats->increment( "wanobjectcache.$kClass.$miss.compute" ); - return $value; + return [ $value, $version, $curInfo['asOf'] ]; + } + + /** + * @param string $key + * @return bool Success + */ + private function claimStampedeLock( $key ) { + // Note that locking is not bypassed due to I/O errors; this avoids stampedes + return $this->cache->add( self::$MUTEX_KEY_PREFIX . $key, 1, self::$LOCK_TTL ); + } + + /** + * @param string $key + * @param bool $hasLock + */ + private function yieldStampedeLock( $key, $hasLock ) { + if ( $hasLock ) { + // The backend might be a mcrouter proxy set to broadcast DELETE to *all* the local + // datacenter cache servers via OperationSelectorRoute (for increased consistency). + // Since that would be excessive for these locks, use TOUCH to expire the key. + $this->cache->changeTTL( self::$MUTEX_KEY_PREFIX . $key, $this->getCurrentTime() - 60 ); + } } /** @@ -1456,7 +1489,7 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { * @return bool Whether the age of a volatile value is negligible */ private function isVolatileValueAgeNegligible( $age ) { - return ( $age < mt_rand( self::RECENT_SET_LOW_MS, self::RECENT_SET_HIGH_MS ) / 1e3 ); + return ( $age < mt_rand( self::$RECENT_SET_LOW_MS, self::$RECENT_SET_HIGH_MS ) / 1e3 ); } /** @@ -1464,10 +1497,12 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { * @param string $kClass * @param float $elapsed Seconds spent regenerating the value * @param float $lockTSE - * @param $hasLock bool + * @param bool $hasLock * @return bool Whether it is OK to proceed with a key set operation */ private function checkAndSetCooloff( $key, $kClass, $elapsed, $lockTSE, $hasLock ) { + $this->stats->timing( "wanobjectcache.$kClass.regen_set_delay", 1e3 * $elapsed ); + // If $lockTSE is set, the lock was bypassed because there was no stale/interim value, // and $elapsed indicates that regeration is slow, then there is a risk of set() // stampedes with large blobs. With a typical scale-out infrastructure, CPU and query @@ -1476,13 +1511,13 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { // consistent hashing). if ( $lockTSE < 0 || $hasLock ) { return true; // either not a priori hot or thread has the lock - } elseif ( $elapsed <= self::SET_DELAY_HIGH_MS * 1e3 ) { + } elseif ( $elapsed <= self::$SET_DELAY_HIGH_MS * 1e3 ) { return true; // not enough time for threads to pile up } $this->cache->clearLastError(); if ( - !$this->cache->add( self::COOLOFF_KEY_PREFIX . $key, 1, self::COOLOFF_TTL ) && + !$this->cache->add( self::$COOLOFF_KEY_PREFIX . $key, 1, self::$COOLOFF_TTL ) && // Don't treat failures due to I/O errors as the key being in cooloff $this->cache->getLastError() === BagOStuff::ERR_NONE ) { @@ -1502,18 +1537,14 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { * @return array (current time left or null, UNIX timestamp of last purge or null) * @note Callable type hints are not used to avoid class-autoloading */ - protected function resolveCTL( $value, $curTTL, $curInfo, $touchedCallback ) { + private function resolveCTL( $value, $curTTL, $curInfo, $touchedCallback ) { if ( $touchedCallback === null || $value === false ) { return [ $curTTL, max( $curInfo['tombAsOf'], $curInfo['lastCKPurge'] ) ]; } - if ( !is_callable( $touchedCallback ) ) { - throw new InvalidArgumentException( "Invalid expiration callback provided." ); - } - $touched = $touchedCallback( $value ); if ( $touched !== null && $touched >= $curInfo['asOf'] ) { - $curTTL = min( $curTTL, self::TINY_NEGATIVE, $curInfo['asOf'] - $touched ); + $curTTL = min( $curTTL, self::$TINY_NEGATIVE, $curInfo['asOf'] - $touched ); } return [ $curTTL, max( $curInfo['tombAsOf'], $curInfo['lastCKPurge'], $touched ) ]; @@ -1526,54 +1557,49 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { * @return float|null UNIX timestamp of last purge or null * @note Callable type hints are not used to avoid class-autoloading */ - protected function resolveTouched( $value, $lastPurge, $touchedCallback ) { - if ( $touchedCallback === null || $value === false ) { - return $lastPurge; - } - - if ( !is_callable( $touchedCallback ) ) { - throw new InvalidArgumentException( "Invalid expiration callback provided." ); - } - - return max( $touchedCallback( $value ), $lastPurge ); + private function resolveTouched( $value, $lastPurge, $touchedCallback ) { + return ( $touchedCallback === null || $value === false ) + ? $lastPurge // nothing to derive the "touched timestamp" from + : max( $touchedCallback( $value ), $lastPurge ); } /** * @param string $key - * @param bool $versioned - * @param float $minTime - * @return array (cached value or false, cached value timestamp or null) + * @param float $minAsOf Minimum acceptable "as of" timestamp + * @return array (cached value or false, cache key metadata map) */ - protected function getInterimValue( $key, $versioned, $minTime ) { - if ( !$this->useInterimHoldOffCaching ) { - return [ false, null ]; // disabled - } + private function getInterimValue( $key, $minAsOf ) { + $now = $this->getCurrentTime(); - $wrapped = $this->cache->get( self::INTERIM_KEY_PREFIX . $key ); - list( $value ) = $this->unwrap( $wrapped, $this->getCurrentTime() ); - $valueAsOf = $wrapped[self::FLD_TIME] ?? null; - if ( $this->isValid( $value, $versioned, $valueAsOf, $minTime ) ) { - return [ $value, $valueAsOf ]; + if ( $this->useInterimHoldOffCaching ) { + $wrapped = $this->cache->get( self::$INTERIM_KEY_PREFIX . $key ); + + list( $value, $keyInfo ) = $this->unwrap( $wrapped, $now ); + if ( $this->isValid( $value, $keyInfo['asOf'], $minAsOf ) ) { + return [ $value, $keyInfo ]; + } } - return [ false, null ]; + return $this->unwrap( false, $now ); } /** * @param string $key * @param mixed $value - * @param int $tempTTL - * @param float $newAsOf + * @param int $ttl + * @param int|null $version Value version number + * @param float $walltime How long it took to generate the value in seconds */ - protected function setInterimValue( $key, $value, $tempTTL, $newAsOf ) { - $wrapped = $this->wrap( $value, $tempTTL, $newAsOf ); + private function setInterimValue( $key, $value, $ttl, $version, $walltime ) { + $ttl = max( self::$INTERIM_KEY_TTL, (int)$ttl ); + $wrapped = $this->wrap( $value, $ttl, $version, $this->getCurrentTime(), $walltime ); $this->cache->merge( - self::INTERIM_KEY_PREFIX . $key, + self::$INTERIM_KEY_PREFIX . $key, function () use ( $wrapped ) { return $wrapped; }, - $tempTTL, + $ttl, 1 ); } @@ -1602,7 +1628,7 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { * // Map of cache keys to entity IDs * $cache->makeMultiKeys( * $this->fileVersionIds(), - * function ( $id, WANObjectCache $cache ) { + * function ( $id ) use ( $cache ) { * return $cache->makeKey( 'file-version', $id ); * } * ), @@ -1641,20 +1667,16 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { * @param int $ttl Seconds to live for key updates * @param callable $callback Callback the yields entity regeneration callbacks * @param array $opts Options map - * @return array Map of (cache key => value) in the same order as $keyedIds + * @return mixed[] Map of (cache key => value) in the same order as $keyedIds * @since 1.28 */ final public function getMultiWithSetCallback( ArrayIterator $keyedIds, $ttl, callable $callback, array $opts = [] ) { - $valueKeys = array_keys( $keyedIds->getArrayCopy() ); - $checkKeys = $opts['checkKeys'] ?? []; - $pcTTL = $opts['pcTTL'] ?? self::TTL_UNCACHEABLE; - // Load required keys into process cache in one go $this->warmupCache = $this->getRawKeysForWarmup( - $this->getNonProcessCachedKeys( $valueKeys, $opts, $pcTTL ), - $checkKeys + $this->getNonProcessCachedMultiKeys( $keyedIds, $opts ), + $opts['checkKeys'] ?? [] ); $this->warmupKeyMisses = 0; @@ -1696,7 +1718,7 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { * // Map of cache keys to entity IDs * $cache->makeMultiKeys( * $this->fileVersionIds(), - * function ( $id, WANObjectCache $cache ) { + * function ( $id ) use ( $cache ) { * return $cache->makeKey( 'file-version', $id ); * } * ), @@ -1736,22 +1758,19 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { * @param int $ttl Seconds to live for key updates * @param callable $callback Callback the yields entity regeneration callbacks * @param array $opts Options map - * @return array Map of (cache key => value) in the same order as $keyedIds + * @return mixed[] Map of (cache key => value) in the same order as $keyedIds * @since 1.30 */ final public function getMultiWithUnionSetCallback( ArrayIterator $keyedIds, $ttl, callable $callback, array $opts = [] ) { - $idsByValueKey = $keyedIds->getArrayCopy(); - $valueKeys = array_keys( $idsByValueKey ); $checkKeys = $opts['checkKeys'] ?? []; - $pcTTL = $opts['pcTTL'] ?? self::TTL_UNCACHEABLE; unset( $opts['lockTSE'] ); // incompatible unset( $opts['busyValue'] ); // incompatible // Load required keys into process cache in one go - $keysGet = $this->getNonProcessCachedKeys( $valueKeys, $opts, $pcTTL ); - $this->warmupCache = $this->getRawKeysForWarmup( $keysGet, $checkKeys ); + $keysByIdGet = $this->getNonProcessCachedMultiKeys( $keyedIds, $opts ); + $this->warmupCache = $this->getRawKeysForWarmup( $keysByIdGet, $checkKeys ); $this->warmupKeyMisses = 0; // IDs of entities known to be in need of regeneration @@ -1760,10 +1779,10 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { // Find out which keys are missing/deleted/stale $curTTLs = []; $asOfs = []; - $curByKey = $this->getMulti( $keysGet, $curTTLs, $checkKeys, $asOfs ); - foreach ( $keysGet as $key ) { + $curByKey = $this->getMulti( $keysByIdGet, $curTTLs, $checkKeys, $asOfs ); + foreach ( $keysByIdGet as $id => $key ) { if ( !array_key_exists( $key, $curByKey ) || $curTTLs[$key] < 0 ) { - $idsRegen[] = $idsByValueKey[$key]; + $idsRegen[] = $id; } } @@ -1795,7 +1814,7 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { // Run the cache-aside logic using warmupCache instead of persistent cache queries $values = []; - foreach ( $idsByValueKey as $key => $id ) { // preserve order + foreach ( $keyedIds as $key => $id ) { // preserve order $values[$key] = $this->getWithSetCallback( $key, $ttl, $func, $opts ); } @@ -1818,12 +1837,12 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { */ final public function reap( $key, $purgeTimestamp, &$isStale = false ) { $minAsOf = $purgeTimestamp + self::HOLDOFF_TTL; - $wrapped = $this->cache->get( self::VALUE_KEY_PREFIX . $key ); - if ( is_array( $wrapped ) && $wrapped[self::FLD_TIME] < $minAsOf ) { + $wrapped = $this->cache->get( self::$VALUE_KEY_PREFIX . $key ); + if ( is_array( $wrapped ) && $wrapped[self::$FLD_TIME] < $minAsOf ) { $isStale = true; $this->logger->warning( "Reaping stale value key '$key'." ); $ttlReap = self::HOLDOFF_TTL; // avoids races with tombstone creation - $ok = $this->cache->changeTTL( self::VALUE_KEY_PREFIX . $key, $ttlReap ); + $ok = $this->cache->changeTTL( self::$VALUE_KEY_PREFIX . $key, $ttlReap ); if ( !$ok ) { $this->logger->error( "Could not complete reap of key '$key'." ); } @@ -1846,11 +1865,11 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { * @since 1.28 */ final public function reapCheckKey( $key, $purgeTimestamp, &$isStale = false ) { - $purge = $this->parsePurgeValue( $this->cache->get( self::TIME_KEY_PREFIX . $key ) ); - if ( $purge && $purge[self::FLD_TIME] < $purgeTimestamp ) { + $purge = $this->parsePurgeValue( $this->cache->get( self::$TIME_KEY_PREFIX . $key ) ); + if ( $purge && $purge[self::$PURGE_TIME] < $purgeTimestamp ) { $isStale = true; $this->logger->warning( "Reaping stale check key '$key'." ); - $ok = $this->cache->changeTTL( self::TIME_KEY_PREFIX . $key, self::TTL_SECOND ); + $ok = $this->cache->changeTTL( self::$TIME_KEY_PREFIX . $key, self::TTL_SECOND ); if ( !$ok ) { $this->logger->error( "Could not complete reap of check key '$key'." ); } @@ -1866,38 +1885,153 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { /** * @see BagOStuff::makeKey() * @param string $class Key class - * @param string|null $component [optional] Key component (starting with a key collection name) - * @return string Colon-delimited list of $keyspace followed by escaped components of $args + * @param string ...$components Key components (starting with a key collection name) + * @return string Colon-delimited list of $keyspace followed by escaped components * @since 1.27 */ - public function makeKey( $class, $component = null ) { + public function makeKey( $class, ...$components ) { return $this->cache->makeKey( ...func_get_args() ); } /** * @see BagOStuff::makeGlobalKey() * @param string $class Key class - * @param string|null $component [optional] Key component (starting with a key collection name) - * @return string Colon-delimited list of $keyspace followed by escaped components of $args + * @param string ...$components Key components (starting with a key collection name) + * @return string Colon-delimited list of $keyspace followed by escaped components * @since 1.27 */ - public function makeGlobalKey( $class, $component = null ) { + public function makeGlobalKey( $class, ...$components ) { return $this->cache->makeGlobalKey( ...func_get_args() ); } /** - * @param array $entities List of entity IDs - * @param callable $keyFunc Callback yielding a key from (entity ID, this WANObjectCache) - * @return ArrayIterator Iterator yielding (cache key => entity ID) in $entities order + * Hash a possibly long string into a suitable component for makeKey()/makeGlobalKey() + * + * @param string $component A raw component used in building a cache key + * @return string 64 character HMAC using a stable secret for public collision resistance + * @since 1.34 + */ + public function hash256( $component ) { + return hash_hmac( 'sha256', $component, $this->secret ); + } + + /** + * Get an iterator of (cache key => entity ID) for a list of entity IDs + * + * The callback takes an ID string and returns a key via makeKey()/makeGlobalKey(). + * There should be no network nor filesystem I/O used in the callback. The entity + * ID/key mapping must be 1:1 or an exception will be thrown. If hashing is needed, + * then use the hash256() method. + * + * Example usage for the default keyspace: + * @code + * $keyedIds = $cache->makeMultiKeys( + * $modules, + * function ( $module ) use ( $cache ) { + * return $cache->makeKey( 'module-info', $module ); + * } + * ); + * @endcode + * + * Example usage for mixed default and global keyspace: + * @code + * $keyedIds = $cache->makeMultiKeys( + * $filters, + * function ( $filter ) use ( $cache ) { + * return ( strpos( $filter, 'central:' ) === 0 ) + * ? $cache->makeGlobalKey( 'regex-filter', $filter ) + * : $cache->makeKey( 'regex-filter', $filter ) + * } + * ); + * @endcode + * + * Example usage with hashing: + * @code + * $keyedIds = $cache->makeMultiKeys( + * $urls, + * function ( $url ) use ( $cache ) { + * return $cache->makeKey( 'url-info', $cache->hash256( $url ) ); + * } + * ); + * @endcode + * + * @see WANObjectCache::makeKey() + * @see WANObjectCache::makeGlobalKey() + * @see WANObjectCache::hash256() + * + * @param string[]|int[] $ids List of entity IDs + * @param callable $keyCallback Function returning makeKey()/makeGlobalKey() on the input ID + * @return ArrayIterator Iterator of (cache key => ID); order of $ids is preserved + * @throws UnexpectedValueException * @since 1.28 */ - final public function makeMultiKeys( array $entities, callable $keyFunc ) { - $map = []; - foreach ( $entities as $entity ) { - $map[$keyFunc( $entity, $this )] = $entity; + final public function makeMultiKeys( array $ids, $keyCallback ) { + $idByKey = []; + foreach ( $ids as $id ) { + // Discourage triggering of automatic makeKey() hashing in some backends + if ( strlen( $id ) > 64 ) { + $this->logger->warning( __METHOD__ . ": long ID '$id'; use hash256()" ); + } + $key = $keyCallback( $id, $this ); + // Edge case: ignore key collisions due to duplicate $ids like "42" and 42 + if ( !isset( $idByKey[$key] ) ) { + $idByKey[$key] = $id; + } elseif ( (string)$id !== (string)$idByKey[$key] ) { + throw new UnexpectedValueException( + "Cache key collision; IDs ('$id','{$idByKey[$key]}') map to '$key'" + ); + } } - return new ArrayIterator( $map ); + return new ArrayIterator( $idByKey ); + } + + /** + * Get an (ID => value) map from (i) a non-unique list of entity IDs, and (ii) the list + * of corresponding entity values by first appearance of each ID in the entity ID list + * + * For use with getMultiWithSetCallback() and getMultiWithUnionSetCallback(). + * + * *Only* use this method if the entity ID/key mapping is trivially 1:1 without exception. + * Key generation method must utitilize the *full* entity ID in the key (not a hash of it). + * + * Example usage: + * @code + * $poems = $cache->getMultiWithSetCallback( + * $cache->makeMultiKeys( + * $uuids, + * function ( $uuid ) use ( $cache ) { + * return $cache->makeKey( 'poem', $uuid ); + * } + * ), + * $cache::TTL_DAY, + * function ( $uuid ) use ( $url ) { + * return $this->http->run( [ 'method' => 'GET', 'url' => "$url/$uuid" ] ); + * } + * ); + * $poemsByUUID = $cache->multiRemap( $uuids, $poems ); + * @endcode + * + * @see WANObjectCache::makeMultiKeys() + * @see WANObjectCache::getMultiWithSetCallback() + * @see WANObjectCache::getMultiWithUnionSetCallback() + * + * @param string[]|int[] $ids Entity ID list makeMultiKeys() + * @param mixed[] $res Result of getMultiWithSetCallback()/getMultiWithUnionSetCallback() + * @return mixed[] Map of (ID => value); order of $ids is preserved + * @since 1.34 + */ + final public function multiRemap( array $ids, array $res ) { + if ( count( $ids ) !== count( $res ) ) { + // If makeMultiKeys() is called on a list of non-unique IDs, then the resulting + // ArrayIterator will have less entries due to "first appearance" de-duplication + $ids = array_keys( array_flip( $ids ) ); + if ( count( $ids ) !== count( $res ) ) { + throw new UnexpectedValueException( "Multi-key result does not match ID list" ); + } + } + + return array_combine( $ids, $res ); } /** @@ -2058,7 +2192,7 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { * This must set the key to "PURGED::" * * @param string $key Cache key - * @param int $ttl How long to keep the tombstone [seconds] + * @param int $ttl Seconds to keep the tombstone around * @param int $holdoff HOLDOFF_* constant controlling how long to ignore sets for this key * @return bool Success */ @@ -2068,14 +2202,14 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { // Wildcards select all matching routes, e.g. the WAN cluster on all DCs $ok = $this->cache->set( "/*/{$this->cluster}/{$key}", - $this->makePurgeValue( $this->getCurrentTime(), self::HOLDOFF_NONE ), + $this->makePurgeValue( $this->getCurrentTime(), self::HOLDOFF_TTL_NONE ), $ttl ); } else { // This handles the mcrouter and the single-DC case $ok = $this->cache->set( $key, - $this->makePurgeValue( $this->getCurrentTime(), self::HOLDOFF_NONE ), + $this->makePurgeValue( $this->getCurrentTime(), self::HOLDOFF_TTL_NONE ), $ttl ); } @@ -2104,10 +2238,11 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { /** * @param string $key - * @param int $ttl + * @param int $ttl Seconds to live * @param callable $callback * @param array $opts * @return bool Success + * @note Callable type hints are not used to avoid class-autoloading */ private function scheduleAsyncRefresh( $key, $ttl, $callback, $opts ) { if ( !$this->asyncHandler ) { @@ -2116,9 +2251,8 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { // Update the cache value later, such during post-send of an HTTP request $func = $this->asyncHandler; $func( function () use ( $key, $ttl, $callback, $opts ) { - $asOf = null; // unused $opts['minAsOf'] = INF; // force a refresh - $this->doGetWithSetCallback( $key, $ttl, $callback, $opts, $asOf ); + $this->fetchOrRegenerate( $key, $ttl, $callback, $opts ); } ); return true; @@ -2137,7 +2271,7 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { * @param int $graceTTL Consider using stale values if $curTTL is greater than this * @return bool */ - protected function isAliveOrInGracePeriod( $curTTL, $graceTTL ) { + private function isAliveOrInGracePeriod( $curTTL, $graceTTL ) { if ( $curTTL > 0 ) { return true; } elseif ( $graceTTL <= 0 ) { @@ -2207,17 +2341,18 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { return false; } + $popularHitsPerSec = 1; // Lifecycle is: new, ramp-up refresh chance, full refresh chance. - // Note that the "expected # of refreshes" for the ramp-up time range is half of what it - // would be if P(refresh) was at its full value during that time range. - $refreshWindowSec = max( $timeTillRefresh - $ageNew - self::RAMPUP_TTL / 2, 1 ); + // Note that the "expected # of refreshes" for the ramp-up time range is half + // of what it would be if P(refresh) was at its full value during that time range. + $refreshWindowSec = max( $timeTillRefresh - $ageNew - self::$RAMPUP_TTL / 2, 1 ); // P(refresh) * (# hits in $refreshWindowSec) = (expected # of refreshes) - // P(refresh) * ($refreshWindowSec * $popularHitsPerSec) = 1 + // P(refresh) * ($refreshWindowSec * $popularHitsPerSec) = 1 (by definition) // P(refresh) = 1/($refreshWindowSec * $popularHitsPerSec) - $chance = 1 / ( self::HIT_RATE_HIGH * $refreshWindowSec ); + $chance = 1 / ( $popularHitsPerSec * $refreshWindowSec ); // Ramp up $chance from 0 to its nominal value over RAMPUP_TTL seconds to avoid stampedes - $chance *= ( $timeOld <= self::RAMPUP_TTL ) ? $timeOld / self::RAMPUP_TTL : 1; + $chance *= ( $timeOld <= self::$RAMPUP_TTL ) ? $timeOld / self::$RAMPUP_TTL : 1; return mt_rand( 1, 1e9 ) <= 1e9 * $chance; } @@ -2226,21 +2361,18 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { * Check if $value is not false, versioned (if needed), and not older than $minTime (if set) * * @param array|bool $value - * @param bool $versioned * @param float $asOf The time $value was generated - * @param float $minTime The last time the main value was generated (0.0 if unknown) + * @param float $minAsOf Minimum acceptable "as of" timestamp * @param float|null $purgeTime The last time the value was invalidated * @return bool */ - protected function isValid( $value, $versioned, $asOf, $minTime, $purgeTime = null ) { + protected function isValid( $value, $asOf, $minAsOf, $purgeTime = null ) { // Avoid reading any key not generated after the latest delete() or touch - $safeMinTime = max( $minTime, $purgeTime + self::TINY_POSTIVE ); + $safeMinAsOf = max( $minAsOf, $purgeTime + self::$TINY_POSTIVE ); if ( $value === false ) { return false; - } elseif ( $versioned && !isset( $value[self::VFLD_VERSION] ) ) { - return false; - } elseif ( $safeMinTime > 0 && $asOf < $minTime ) { + } elseif ( $safeMinAsOf > 0 && $asOf < $minAsOf ) { return false; } @@ -2248,68 +2380,82 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { } /** - * Do not use this method outside WANObjectCache - * * @param mixed $value - * @param int $ttl [0=forever] + * @param int $ttl Seconds to live or zero for "indefinite" + * @param int|null $version Value version number or null if not versioned * @param float $now Unix Current timestamp just before calling set() + * @param float $walltime How long it took to generate the value in seconds * @return array */ - protected function wrap( $value, $ttl, $now ) { - return [ - self::FLD_VERSION => self::VERSION, - self::FLD_VALUE => $value, - self::FLD_TTL => $ttl, - self::FLD_TIME => $now + private function wrap( $value, $ttl, $version, $now, $walltime ) { + // Returns keys in ascending integer order for PHP7 array packing: + // https://nikic.github.io/2014/12/22/PHPs-new-hashtable-implementation.html + $wrapped = [ + self::$FLD_FORMAT_VERSION => self::$VERSION, + self::$FLD_VALUE => $value, + self::$FLD_TTL => $ttl, + self::$FLD_TIME => $now ]; + if ( $version !== null ) { + $wrapped[self::$FLD_VALUE_VERSION] = $version; + } + if ( $walltime >= self::$GENERATION_SLOW_SEC ) { + $wrapped[self::$FLD_GENERATION_TIME] = $walltime; + } + + return $wrapped; } /** - * Do not use this method outside WANObjectCache - * - * The cached value will be false if absent/tombstoned/malformed - * - * @param array|string|bool $wrapped + * @param array|string|bool $wrapped The entry at a cache key * @param float $now Unix Current timestamp (preferrably pre-query) - * @return array (cached value or false, current TTL, value timestamp, tombstone timestamp) + * @return array (value or false if absent/tombstoned/malformed, value metadata map). + * The cache key metadata includes the following metadata: + * - asOf: UNIX timestamp of the value or null if there is no value + * - curTTL: remaining time-to-live (negative if tombstoned) or null if there is no value + * - version: value version number or null if the if there is no value + * - tombAsOf: UNIX timestamp of the tombstone or null if there is no tombstone */ - protected function unwrap( $wrapped, $now ) { - // Check if the value is a tombstone - $purge = $this->parsePurgeValue( $wrapped ); - if ( $purge !== false ) { - // Purged values should always have a negative current $ttl - $curTTL = min( $purge[self::FLD_TIME] - $now, self::TINY_NEGATIVE ); - return [ false, $curTTL, null, $purge[self::FLD_TIME] ]; - } - - if ( !is_array( $wrapped ) // not found - || !isset( $wrapped[self::FLD_VERSION] ) // wrong format - || $wrapped[self::FLD_VERSION] !== self::VERSION // wrong version - ) { - return [ false, null, null, null ]; - } - - if ( $wrapped[self::FLD_TTL] > 0 ) { - // Get the approximate time left on the key - $age = $now - $wrapped[self::FLD_TIME]; - $curTTL = max( $wrapped[self::FLD_TTL] - $age, 0.0 ); + private function unwrap( $wrapped, $now ) { + $value = false; + $info = [ 'asOf' => null, 'curTTL' => null, 'version' => null, 'tombAsOf' => null ]; + + if ( is_array( $wrapped ) ) { + // Entry expected to be a cached value; validate it + if ( + ( $wrapped[self::$FLD_FORMAT_VERSION] ?? null ) === self::$VERSION && + $wrapped[self::$FLD_TIME] >= $this->epoch + ) { + if ( $wrapped[self::$FLD_TTL] > 0 ) { + // Get the approximate time left on the key + $age = $now - $wrapped[self::$FLD_TIME]; + $curTTL = max( $wrapped[self::$FLD_TTL] - $age, 0.0 ); + } else { + // Key had no TTL, so the time left is unbounded + $curTTL = INF; + } + $value = $wrapped[self::$FLD_VALUE]; + $info['version'] = $wrapped[self::$FLD_VALUE_VERSION] ?? null; + $info['asOf'] = $wrapped[self::$FLD_TIME]; + $info['curTTL'] = $curTTL; + } } else { - // Key had no TTL, so the time left is unbounded - $curTTL = INF; - } - - if ( $wrapped[self::FLD_TIME] < $this->epoch ) { - // Values this old are ignored - return [ false, null, null, null ]; + // Entry expected to be a tombstone; parse it + $purge = $this->parsePurgeValue( $wrapped ); + if ( $purge !== false ) { + // Tombstoned keys should always have a negative current $ttl + $info['curTTL'] = min( $purge[self::$PURGE_TIME] - $now, self::$TINY_NEGATIVE ); + $info['tombAsOf'] = $purge[self::$PURGE_TIME]; + } } - return [ $wrapped[self::FLD_VALUE], $curTTL, $wrapped[self::FLD_TIME], null ]; + return [ $value, $info ]; } /** - * @param array $keys + * @param string[] $keys * @param string $prefix - * @return string[] + * @return string[] Prefix keys; the order of $keys is preserved */ protected static function prefixCacheKeys( array $keys, $prefix ) { $res = []; @@ -2324,7 +2470,7 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { * @param string $key String of the format :[:]... * @return string A collection name to describe this class of key */ - protected function determineKeyClassForStats( $key ) { + private function determineKeyClassForStats( $key ) { $parts = explode( ':', $key, 3 ); return $parts[1] ?? $parts[0]; // sanity @@ -2335,14 +2481,16 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { * @return array|bool Array containing a UNIX timestamp (float) and holdoff period (integer), * or false if value isn't a valid purge value */ - protected function parsePurgeValue( $value ) { + private function parsePurgeValue( $value ) { if ( !is_string( $value ) ) { return false; } $segments = explode( ':', $value, 3 ); - if ( !isset( $segments[0] ) || !isset( $segments[1] ) - || "{$segments[0]}:" !== self::PURGE_VAL_PREFIX + if ( + !isset( $segments[0] ) || + !isset( $segments[1] ) || + "{$segments[0]}:" !== self::$PURGE_VAL_PREFIX ) { return false; } @@ -2358,8 +2506,8 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { } return [ - self::FLD_TIME => (float)$segments[1], - self::FLD_HOLDOFF => (int)$segments[2], + self::$PURGE_TIME => (float)$segments[1], + self::$PURGE_HOLDOFF => (int)$segments[2], ]; } @@ -2368,48 +2516,58 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { * @param int $holdoff In seconds * @return string Wrapped purge value */ - protected function makePurgeValue( $timestamp, $holdoff ) { - return self::PURGE_VAL_PREFIX . (float)$timestamp . ':' . (int)$holdoff; + private function makePurgeValue( $timestamp, $holdoff ) { + return self::$PURGE_VAL_PREFIX . (float)$timestamp . ':' . (int)$holdoff; } /** * @param string $group * @return MapCacheLRU */ - protected function getProcessCache( $group ) { + private function getProcessCache( $group ) { if ( !isset( $this->processCaches[$group] ) ) { - list( , $n ) = explode( ':', $group ); - $this->processCaches[$group] = new MapCacheLRU( (int)$n ); + list( , $size ) = explode( ':', $group ); + $this->processCaches[$group] = new MapCacheLRU( (int)$size ); } return $this->processCaches[$group]; } /** - * @param array $keys + * @param string $key + * @param int $version + * @return string + */ + private function getProcessCacheKey( $key, $version ) { + return $key . ' ' . (int)$version; + } + + /** + * @param ArrayIterator $keys * @param array $opts - * @param int $pcTTL - * @return array List of keys + * @return string[] Map of (ID => cache key) */ - private function getNonProcessCachedKeys( array $keys, array $opts, $pcTTL ) { - $keysFound = []; - if ( isset( $opts['pcTTL'] ) && $opts['pcTTL'] > 0 && $this->callbackDepth == 0 ) { - $pcGroup = $opts['pcGroup'] ?? self::PC_PRIMARY; - $procCache = $this->getProcessCache( $pcGroup ); - foreach ( $keys as $key ) { - if ( $procCache->has( $key, $pcTTL ) ) { - $keysFound[] = $key; + private function getNonProcessCachedMultiKeys( ArrayIterator $keys, array $opts ) { + $pcTTL = $opts['pcTTL'] ?? self::TTL_UNCACHEABLE; + + $keysMissing = []; + if ( $pcTTL > 0 && $this->callbackDepth == 0 ) { + $version = $opts['version'] ?? null; + $pCache = $this->getProcessCache( $opts['pcGroup'] ?? self::PC_PRIMARY ); + foreach ( $keys as $key => $id ) { + if ( !$pCache->has( $this->getProcessCacheKey( $key, $version ), $pcTTL ) ) { + $keysMissing[$id] = $key; } } } - return array_diff( $keys, $keysFound ); + return $keysMissing; } /** - * @param array $keys - * @param array $checkKeys - * @return array Map of (cache key => mixed) + * @param string[] $keys + * @param string[]|string[][] $checkKeys + * @return string[] List of cache keys */ private function getRawKeysForWarmup( array $keys, array $checkKeys ) { if ( !$keys ) { @@ -2419,18 +2577,18 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { $keysWarmUp = []; // Get all the value keys to fetch... foreach ( $keys as $key ) { - $keysWarmUp[] = self::VALUE_KEY_PREFIX . $key; + $keysWarmUp[] = self::$VALUE_KEY_PREFIX . $key; } // Get all the check keys to fetch... foreach ( $checkKeys as $i => $checkKeyOrKeys ) { if ( is_int( $i ) ) { // Single check key that applies to all value keys - $keysWarmUp[] = self::TIME_KEY_PREFIX . $checkKeyOrKeys; + $keysWarmUp[] = self::$TIME_KEY_PREFIX . $checkKeyOrKeys; } else { // List of check keys that apply to value key $i $keysWarmUp = array_merge( $keysWarmUp, - self::prefixCacheKeys( $checkKeyOrKeys, self::TIME_KEY_PREFIX ) + self::prefixCacheKeys( $checkKeyOrKeys, self::$TIME_KEY_PREFIX ) ); } } diff --git a/includes/libs/objectcache/WinCacheBagOStuff.php b/includes/libs/objectcache/WinCacheBagOStuff.php index 8c419b22cd..0e4e3fb63d 100644 --- a/includes/libs/objectcache/WinCacheBagOStuff.php +++ b/includes/libs/objectcache/WinCacheBagOStuff.php @@ -27,16 +27,16 @@ * * @ingroup Cache */ -class WinCacheBagOStuff extends BagOStuff { +class WinCacheBagOStuff extends MediumSpecificBagOStuff { protected function doGet( $key, $flags = 0, &$casToken = null ) { $casToken = null; $blob = wincache_ucache_get( $key ); - if ( !is_string( $blob ) ) { + if ( !is_string( $blob ) && !is_int( $blob ) ) { return false; } - $value = unserialize( $blob ); + $value = $this->unserialize( $blob ); if ( $value !== false ) { $casToken = (string)$blob; // don't bother hashing this } @@ -67,8 +67,8 @@ class WinCacheBagOStuff extends BagOStuff { return $success; } - public function set( $key, $value, $expire = 0, $flags = 0 ) { - $result = wincache_ucache_set( $key, serialize( $value ), $expire ); + protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) { + $result = wincache_ucache_set( $key, $this->serialize( $value ), $exptime ); // false positive, wincache_ucache_set returns an empty array // in some circumstances. @@ -77,7 +77,11 @@ class WinCacheBagOStuff extends BagOStuff { } public function add( $key, $value, $exptime = 0, $flags = 0 ) { - $result = wincache_ucache_add( $key, serialize( $value ), $exptime ); + if ( wincache_ucache_exists( $key ) ) { + return false; // avoid warnings + } + + $result = wincache_ucache_add( $key, $this->serialize( $value ), $exptime ); // false positive, wincache_ucache_add returns an empty array // in some circumstances. @@ -85,7 +89,7 @@ class WinCacheBagOStuff extends BagOStuff { return ( $result === [] || $result === true ); } - public function delete( $key, $flags = 0 ) { + protected function doDelete( $key, $flags = 0 ) { wincache_ucache_delete( $key ); return true; diff --git a/includes/libs/objectcache/serialized/SerializedValueContainer.php b/includes/libs/objectcache/serialized/SerializedValueContainer.php new file mode 100644 index 0000000000..7c7d8aa153 --- /dev/null +++ b/includes/libs/objectcache/serialized/SerializedValueContainer.php @@ -0,0 +1,66 @@ + self::SCHEMA_UNIFIED, + self::UNIFIED_DATA => $serialized + ]; + } + + /** + * @param string[] $segmentHashList Ordered list of hashes for each segment + * @return stdClass + */ + public static function newSegmented( array $segmentHashList ) { + return (object)[ + self::SCHEMA => self::SCHEMA_SEGMENTED, + self::SEGMENTED_HASHES => $segmentHashList + ]; + } + + /** + * @param mixed $value + * @return bool + */ + public static function isUnified( $value ) { + return self::instanceOf( $value, self::SCHEMA_UNIFIED ); + } + + /** + * @param mixed $value + * @return bool + */ + public static function isSegmented( $value ) { + return self::instanceOf( $value, self::SCHEMA_SEGMENTED ); + } + + /** + * @param mixed $value + * @param string $schema SCHEMA_* class constant + * @return bool + */ + private static function instanceOf( $value, $schema ) { + return ( + $value instanceof stdClass && + property_exists( $value, self::SCHEMA ) && + $value->{self::SCHEMA} === $schema + ); + } +} diff --git a/includes/libs/rdbms/ChronologyProtector.php b/includes/libs/rdbms/ChronologyProtector.php index 88bc049386..8615cfc630 100644 --- a/includes/libs/rdbms/ChronologyProtector.php +++ b/includes/libs/rdbms/ChronologyProtector.php @@ -176,8 +176,8 @@ class ChronologyProtector implements LoggerAwareInterface { } $masterName = $lb->getServerName( $lb->getWriterIndex() ); - if ( $lb->getServerCount() > 1 ) { - $pos = $lb->getMasterPos(); + if ( $lb->hasStreamingReplicaServers() ) { + $pos = $lb->getReplicaResumePos(); if ( $pos ) { $this->logger->debug( __METHOD__ . ": LB for '$masterName' has pos $pos\n" ); $this->shutdownPositions[$masterName] = $pos; diff --git a/includes/libs/rdbms/connectionmanager/ConnectionManager.php b/includes/libs/rdbms/connectionmanager/ConnectionManager.php index 27e6138102..50a0b0e1f8 100644 --- a/includes/libs/rdbms/connectionmanager/ConnectionManager.php +++ b/includes/libs/rdbms/connectionmanager/ConnectionManager.php @@ -73,7 +73,7 @@ class ConnectionManager { * @param int $i * @param string[]|null $groups * - * @return Database + * @return IDatabase */ private function getConnection( $i, array $groups = null ) { $groups = $groups === null ? $this->groups : $groups; @@ -97,7 +97,7 @@ class ConnectionManager { * * @since 1.29 * - * @return Database + * @return IDatabase */ public function getWriteConnection() { return $this->getConnection( DB_MASTER ); @@ -111,7 +111,7 @@ class ConnectionManager { * * @param string[]|null $groups * - * @return Database + * @return IDatabase */ public function getReadConnection( array $groups = null ) { $groups = $groups === null ? $this->groups : $groups; diff --git a/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManager.php b/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManager.php index aa3bea8fdc..ccb73d7256 100644 --- a/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManager.php +++ b/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManager.php @@ -64,7 +64,7 @@ class SessionConsistentConnectionManager extends ConnectionManager { * * @param string[]|null $groups * - * @return Database + * @return IDatabase */ public function getReadConnection( array $groups = null ) { if ( $this->forceWriteConnection ) { @@ -77,7 +77,7 @@ class SessionConsistentConnectionManager extends ConnectionManager { /** * @since 1.29 * - * @return Database + * @return IDatabase */ public function getWriteConnection() { $this->prepareForUpdates(); diff --git a/includes/libs/rdbms/database/DBConnRef.php b/includes/libs/rdbms/database/DBConnRef.php index b216892486..2c9858add5 100644 --- a/includes/libs/rdbms/database/DBConnRef.php +++ b/includes/libs/rdbms/database/DBConnRef.php @@ -5,8 +5,23 @@ namespace Wikimedia\Rdbms; use InvalidArgumentException; /** - * Helper class to handle automatically marking connections as reusable (via RAII pattern) - * as well handling deferring the actual network connection until the handle is used + * Helper class used for automatically marking an IDatabase connection as reusable (once it no + * longer matters which DB domain is selected) and for deferring the actual network connection + * + * This uses an RAII-style pattern where calling code is expected to keep the returned reference + * handle as a function variable that falls out of scope when no longer needed. This avoids the + * need for matching reuseConnection() calls for every "return" statement as well as the tedious + * use of try/finally. + * + * @par Example: + * @code + * function getRowData() { + * $conn = $this->lb->getConnectedRef( DB_REPLICA ); + * $row = $conn->select( ... ); + * return $row ? (array)$row : false; + * // $conn falls out of scope and $this->lb->reuseConnection() gets called + * } + * @endcode * * @ingroup Database * @since 1.22 @@ -28,14 +43,14 @@ class DBConnRef implements IDatabase { /** * @param ILoadBalancer $lb Connection manager for $conn - * @param Database|array $conn Database or (server index, query groups, domain, flags) + * @param IDatabase|array $conn Database or (server index, query groups, domain, flags) * @param int $role The type of connection asked for; one of DB_MASTER/DB_REPLICA * @internal This method should not be called outside of LoadBalancer */ public function __construct( ILoadBalancer $lb, $conn, $role ) { $this->lb = $lb; $this->role = $role; - if ( $conn instanceof Database ) { + if ( $conn instanceof IDatabase && !( $conn instanceof DBConnRef ) ) { $this->conn = $conn; // live handle } elseif ( is_array( $conn ) && count( $conn ) >= 4 && $conn[self::FLD_DOMAIN] !== false ) { $this->params = $conn; @@ -115,7 +130,7 @@ class DBConnRef implements IDatabase { return $this->__call( __FUNCTION__, func_get_args() ); } - public function setLBInfo( $name, $value = null ) { + public function setLBInfo( $nameOrArray, $value = null ) { // Disallow things that might confuse the LoadBalancer tracking throw new DBUnexpectedError( $this, "Changing LB info is disallowed to enable reuse." ); } @@ -461,7 +476,7 @@ class DBConnRef implements IDatabase { return $this->__call( __FUNCTION__, func_get_args() ); } - public function buildLike() { + public function buildLike( $param ) { return $this->__call( __FUNCTION__, func_get_args() ); } @@ -598,6 +613,10 @@ class DBConnRef implements IDatabase { return $this->__call( __FUNCTION__, func_get_args() ); } + public function onAtomicSectionCancel( callable $callback, $fname = __METHOD__ ) { + return $this->__call( __FUNCTION__, func_get_args() ); + } + public function setTransactionListener( $name, callable $callback = null ) { return $this->__call( __FUNCTION__, func_get_args() ); } @@ -630,15 +649,15 @@ class DBConnRef implements IDatabase { return $this->__call( __FUNCTION__, func_get_args() ); } - public function commit( $fname = __METHOD__, $flush = '' ) { + public function commit( $fname = __METHOD__, $flush = self::FLUSHING_ONE ) { return $this->__call( __FUNCTION__, func_get_args() ); } - public function rollback( $fname = __METHOD__, $flush = '' ) { + public function rollback( $fname = __METHOD__, $flush = self::FLUSHING_ONE ) { return $this->__call( __FUNCTION__, func_get_args() ); } - public function flushSnapshot( $fname = __METHOD__ ) { + public function flushSnapshot( $fname = __METHOD__, $flush = self::FLUSHING_ONE ) { return $this->__call( __FUNCTION__, func_get_args() ); } @@ -740,6 +759,19 @@ class DBConnRef implements IDatabase { return $this->__call( __FUNCTION__, func_get_args() ); } + public function __toString() { + if ( $this->conn === null ) { + // spl_object_id is PHP >= 7.2 + $id = function_exists( 'spl_object_id' ) + ? spl_object_id( $this ) + : spl_object_hash( $this ); + + return $this->getType() . ' object #' . $id; + } + + return $this->__call( __FUNCTION__, func_get_args() ); + } + /** * Error out if the role is not DB_MASTER * diff --git a/includes/libs/rdbms/database/Database.php b/includes/libs/rdbms/database/Database.php index fe23a384a4..60062fbc13 100644 --- a/includes/libs/rdbms/database/Database.php +++ b/includes/libs/rdbms/database/Database.php @@ -30,7 +30,7 @@ use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Wikimedia\ScopedCallback; use Wikimedia\Timestamp\ConvertibleTimestamp; -use Wikimedia; +use Wikimedia\AtEase\AtEase; use BagOStuff; use HashBagOStuff; use LogicException; @@ -38,6 +38,7 @@ use InvalidArgumentException; use UnexpectedValueException; use Exception; use RuntimeException; +use Throwable; /** * Relational database abstraction object @@ -46,38 +47,6 @@ use RuntimeException; * @since 1.28 */ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAwareInterface { - /** Number of times to re-try an operation in case of deadlock */ - const DEADLOCK_TRIES = 4; - /** Minimum time to wait before retry, in microseconds */ - const DEADLOCK_DELAY_MIN = 500000; - /** Maximum time to wait before retry */ - const DEADLOCK_DELAY_MAX = 1500000; - - /** How long before it is worth doing a dummy query to test the connection */ - const PING_TTL = 1.0; - const PING_QUERY = 'SELECT 1 AS ping'; - - const TINY_WRITE_SEC = 0.010; - const SLOW_WRITE_SEC = 0.500; - const SMALL_WRITE_ROWS = 100; - - /** @var string Lock granularity is on the level of the entire database */ - const ATTR_DB_LEVEL_LOCKING = 'db-level-locking'; - /** @var string The SCHEMA keyword refers to a grouping of tables in a database */ - const ATTR_SCHEMAS_AS_TABLE_GROUPS = 'supports-schemas'; - - /** @var int New Database instance will not be connected yet when returned */ - const NEW_UNCONNECTED = 0; - /** @var int New Database instance will already be connected when returned */ - const NEW_CONNECTED = 1; - - /** @var string The last SQL query attempted */ - private $lastQuery = ''; - /** @var float|bool UNIX timestamp of last write query */ - private $lastWriteTime = false; - /** @var string|bool */ - private $lastPhpError = false; - /** @var string Server that this instance is currently connected to */ protected $server; /** @var string User that this instance is currently connected under the name of */ @@ -92,8 +61,23 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware protected $cliMode; /** @var string Agent name for query profiling */ protected $agent; + /** @var int Bitfield of class DBO_* constants */ + protected $flags; + /** @var array LoadBalancer tracking information */ + protected $lbInfo = []; + /** @var array|bool Variables use for schema element placeholders */ + protected $schemaVars = false; /** @var array Parameters used by initConnection() to establish a connection */ protected $connectionParams = []; + /** @var array SQL variables values to use for all new connections */ + protected $connectionVariables = []; + /** @var string Current SQL query delimiter */ + protected $delimiter = ';'; + /** @var string|bool|null Stashed value of html_errors INI setting */ + protected $htmlErrors; + /** @var int Row batch size to use for emulated INSERT SELECT queries */ + protected $nonNativeInsertSelectBatchSize = 10000; + /** @var BagOStuff APC cache */ protected $srvCache; /** @var LoggerInterface */ @@ -104,177 +88,98 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware protected $errorLogger; /** @var callable Deprecation logging callback */ protected $deprecationLogger; - + /** @var callable|null */ + protected $profiler; + /** @var TransactionProfiler */ + protected $trxProfiler; + /** @var DatabaseDomain */ + protected $currentDomain; /** @var object|resource|null Database connection */ - protected $conn = null; - /** @var bool */ - protected $opened = false; + protected $conn; - /** @var array[] List of (callable, method name, atomic section id) */ - protected $trxIdleCallbacks = []; - /** @var array[] List of (callable, method name, atomic section id) */ - protected $trxPreCommitCallbacks = []; - /** @var array[] List of (callable, method name, atomic section id) */ - protected $trxEndCallbacks = []; - /** @var callable[] Map of (name => callable) */ - protected $trxRecurringCallbacks = []; - /** @var bool Whether to suppress triggering of transaction end callbacks */ - protected $trxEndCallbacksSuppressed = false; + /** @var IDatabase|null Lazy handle to the master DB this server replicates from */ + private $lazyMasterHandle; - /** @var int */ - protected $flags; - /** @var array */ - protected $lbInfo = []; - /** @var array|bool */ - protected $schemaVars = false; - /** @var array */ - protected $sessionVars = []; - /** @var array|null */ - protected $preparedArgs; - /** @var string|bool|null Stashed value of html_errors INI setting */ - protected $htmlErrors; - /** @var string */ - protected $delimiter = ';'; - /** @var DatabaseDomain */ - protected $currentDomain; - /** @var integer|null Rows affected by the last query to query() or its CRUD wrappers */ - protected $affectedRowCount; + /** @var array Map of (name => 1) for locks obtained via lock() */ + protected $sessionNamedLocks = []; + /** @var array Map of (table name => 1) for TEMPORARY tables */ + protected $sessionTempTables = []; - /** - * @var int Transaction status - */ + /** @var string ID of the active transaction or the empty string otherwise */ + protected $trxShortId = ''; + /** @var int Transaction status */ protected $trxStatus = self::STATUS_TRX_NONE; - /** - * @var Exception|null The last error that caused the status to become STATUS_TRX_ERROR - */ + /** @var Exception|null The last error that caused the status to become STATUS_TRX_ERROR */ protected $trxStatusCause; - /** - * @var array|null If wasKnownStatementRollbackError() prevented trxStatus from being set, - * the relevant details are stored here. - */ - protected $trxStatusIgnoredCause; - /** - * Either 1 if a transaction is active or 0 otherwise. - * The other Trx fields may not be meaningfull if this is 0. - * - * @var int - */ - protected $trxLevel = 0; - /** - * Either a short hexidecimal string if a transaction is active or "" - * - * @var string - * @see Database::trxLevel - */ - protected $trxShortId = ''; - /** - * The UNIX time that the transaction started. Callers can assume that if - * snapshot isolation is used, then the data is *at least* up to date to that - * point (possibly more up-to-date since the first SELECT defines the snapshot). - * - * @var float|null - * @see Database::trxLevel - */ + /** @var array|null Error details of the last statement-only rollback */ + private $trxStatusIgnoredCause; + /** @var float|null UNIX timestamp at the time of BEGIN for the last transaction */ private $trxTimestamp = null; - /** @var float Lag estimate at the time of BEGIN */ + /** @var float Replication lag estimate at the time of BEGIN for the last transaction */ private $trxReplicaLag = null; - /** - * Remembers the function name given for starting the most recent transaction via begin(). - * Used to provide additional context for error reporting. - * - * @var string - * @see Database::trxLevel - */ + /** @var string Name of the function that start the last transaction */ private $trxFname = null; - /** - * Record if possible write queries were done in the last transaction started - * - * @var bool - * @see Database::trxLevel - */ + /** @var bool Whether possible write queries were done in the last transaction started */ private $trxDoneWrites = false; - /** - * Record if the current transaction was started implicitly due to DBO_TRX being set. - * - * @var bool - * @see Database::trxLevel - */ + /** @var bool Whether the current transaction was started implicitly due to DBO_TRX */ private $trxAutomatic = false; - /** - * Counter for atomic savepoint identifiers. Reset when a new transaction begins. - * - * @var int - */ + /** @var int Counter for atomic savepoint identifiers (reset with each transaction) */ private $trxAtomicCounter = 0; - /** - * Array of levels of atomicity within transactions - * - * @var array List of (name, unique ID, savepoint ID) - */ + /** @var array List of (name, unique ID, savepoint ID) for each active atomic section level */ private $trxAtomicLevels = []; - /** - * Record if the current transaction was started implicitly by Database::startAtomic - * - * @var bool - */ + /** @var bool Whether the current transaction was started implicitly by startAtomic() */ private $trxAutomaticAtomic = false; - /** - * Track the write query callers of the current transaction - * - * @var string[] - */ + /** @var string[] Write query callers of the current transaction */ private $trxWriteCallers = []; - /** - * @var float Seconds spent in write queries for the current transaction - */ + /** @var float Seconds spent in write queries for the current transaction */ private $trxWriteDuration = 0.0; - /** - * @var int Number of write queries for the current transaction - */ + /** @var int Number of write queries for the current transaction */ private $trxWriteQueryCount = 0; - /** - * @var int Number of rows affected by write queries for the current transaction - */ + /** @var int Number of rows affected by write queries for the current transaction */ private $trxWriteAffectedRows = 0; - /** - * @var float Like trxWriteQueryCount but excludes lock-bound, easy to replicate, queries - */ + /** @var float Like trxWriteQueryCount but excludes lock-bound, easy to replicate, queries */ private $trxWriteAdjDuration = 0.0; - /** - * @var int Number of write queries counted in trxWriteAdjDuration - */ + /** @var int Number of write queries counted in trxWriteAdjDuration */ private $trxWriteAdjQueryCount = 0; - /** - * @var float RTT time estimate - */ - private $rttEstimate = 0.0; - - /** @var array Map of (name => 1) for locks obtained via lock() */ - private $namedLocksHeld = []; - /** @var array Map of (table name => 1) for TEMPORARY tables */ - protected $sessionTempTables = []; - - /** @var IDatabase|null Lazy handle to the master DB this server replicates from */ - private $lazyMasterHandle; - - /** @var float UNIX timestamp */ - protected $lastPing = 0.0; + /** @var array[] List of (callable, method name, atomic section id) */ + private $trxIdleCallbacks = []; + /** @var array[] List of (callable, method name, atomic section id) */ + private $trxPreCommitCallbacks = []; + /** @var array[] List of (callable, method name, atomic section id) */ + private $trxEndCallbacks = []; + /** @var array[] List of (callable, method name, atomic section id) */ + private $trxSectionCancelCallbacks = []; + /** @var callable[] Map of (name => callable) */ + private $trxRecurringCallbacks = []; + /** @var bool Whether to suppress triggering of transaction end callbacks */ + private $trxEndCallbacksSuppressed = false; /** @var int[] Prior flags member variable values */ private $priorFlags = []; - /** @var callable|null */ - protected $profiler; - /** @var TransactionProfiler */ - protected $trxProfiler; + /** @var integer|null Rows affected by the last query to query() or its CRUD wrappers */ + protected $affectedRowCount; - /** @var int */ - protected $nonNativeInsertSelectBatchSize = 10000; + /** @var float UNIX timestamp */ + private $lastPing = 0.0; + /** @var string The last SQL query attempted */ + private $lastQuery = ''; + /** @var float|bool UNIX timestamp of last write query */ + private $lastWriteTime = false; + /** @var string|bool */ + private $lastPhpError = false; + /** @var float Query rount trip time estimate */ + private $lastRoundTripEstimate = 0.0; - /** @var string Idiom used when a cancelable atomic section started the transaction */ - private static $NOT_APPLICABLE = 'n/a'; - /** @var string Prefix to the atomic section counter used to make savepoint IDs */ - private static $SAVEPOINT_PREFIX = 'wikimedia_rdbms_atomic'; + /** @var string Lock granularity is on the level of the entire database */ + const ATTR_DB_LEVEL_LOCKING = 'db-level-locking'; + /** @var string The SCHEMA keyword refers to a grouping of tables in a database */ + const ATTR_SCHEMAS_AS_TABLE_GROUPS = 'supports-schemas'; + + /** @var int New Database instance will not be connected yet when returned */ + const NEW_UNCONNECTED = 0; + /** @var int New Database instance will already be connected when returned */ + const NEW_CONNECTED = 1; /** @var int Transaction is in a error state requiring a full or savepoint rollback */ const STATUS_TRX_ERROR = 1; @@ -283,10 +188,34 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware /** @var int No transaction is active */ const STATUS_TRX_NONE = 3; + /** @var string Idiom used when a cancelable atomic section started the transaction */ + private static $NOT_APPLICABLE = 'n/a'; + /** @var string Prefix to the atomic section counter used to make savepoint IDs */ + private static $SAVEPOINT_PREFIX = 'wikimedia_rdbms_atomic'; + /** @var int Writes to this temporary table do not affect lastDoneWrites() */ - const TEMP_NORMAL = 1; + private static $TEMP_NORMAL = 1; /** @var int Writes to this temporary table effect lastDoneWrites() */ - const TEMP_PSEUDO_PERMANENT = 2; + private static $TEMP_PSEUDO_PERMANENT = 2; + + /** @var int Number of times to re-try an operation in case of deadlock */ + private static $DEADLOCK_TRIES = 4; + /** @var int Minimum time to wait before retry, in microseconds */ + private static $DEADLOCK_DELAY_MIN = 500000; + /** @var int Maximum time to wait before retry */ + private static $DEADLOCK_DELAY_MAX = 1500000; + + /** @var int How long before it is worth doing a dummy query to test the connection */ + private static $PING_TTL = 1.0; + /** @var string Dummy SQL query */ + private static $PING_QUERY = 'SELECT 1 AS ping'; + + /** @var float Guess of how many seconds it takes to replicate a small insert */ + private static $TINY_WRITE_SEC = 0.010; + /** @var float Consider a write slow if it took more than this many seconds */ + private static $SLOW_WRITE_SEC = 0.500; + /** @var float Assume an insert of this many rows or less should be fast to replicate */ + private static $SMALL_WRITE_ROWS = 100; /** * @note exceptions for missing libraries/drivers should be thrown in initConnection() @@ -312,7 +241,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware // Disregard deprecated DBO_IGNORE flag (T189999) $this->flags &= ~self::DBO_IGNORE; - $this->sessionVars = $params['variables']; + $this->connectionVariables = $params['variables']; $this->srvCache = $params['srvCache'] ?? new HashBagOStuff(); @@ -345,7 +274,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware */ final public function initConnection() { if ( $this->isOpen() ) { - throw new LogicException( __METHOD__ . ': already connected.' ); + throw new LogicException( __METHOD__ . ': already connected' ); } // Establish the connection $this->doInitConnection(); @@ -369,20 +298,19 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $this->connectionParams['tablePrefix'] ); } else { - throw new InvalidArgumentException( "No database user provided." ); + throw new InvalidArgumentException( "No database user provided" ); } } /** * Open a new connection to the database (closing any existing one) * - * @param string $server Database server host - * @param string $user Database user name - * @param string $password Database user password - * @param string $dbName Database name + * @param string|null $server Database server host + * @param string|null $user Database user name + * @param string|null $password Database user password + * @param string|null $dbName Database name * @param string|null $schema Database schema name * @param string $tablePrefix Table prefix - * @return bool * @throws DBConnectionError */ abstract protected function open( $server, $user, $password, $dbName, $schema, $tablePrefix ); @@ -392,8 +320,8 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware * * This also connects to the database immediately upon object construction * - * @param string $dbType A possible DB type (sqlite, mysql, postgres,...) - * @param array $p Parameter map with keys: + * @param string $type A possible DB type (sqlite, mysql, postgres,...) + * @param array $params Parameter map with keys: * - host : The hostname of the DB server * - user : The name of the database user the client operates under * - password : The password for the database user @@ -432,45 +360,51 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware * @throws InvalidArgumentException If the database driver or extension cannot be found * @since 1.18 */ - final public static function factory( $dbType, $p = [], $connect = self::NEW_CONNECTED ) { - $class = self::getClass( $dbType, $p['driver'] ?? null ); + final public static function factory( $type, $params = [], $connect = self::NEW_CONNECTED ) { + $class = self::getClass( $type, $params['driver'] ?? null ); if ( class_exists( $class ) && is_subclass_of( $class, IDatabase::class ) ) { - // Resolve some defaults for b/c - $p['host'] = $p['host'] ?? false; - $p['user'] = $p['user'] ?? false; - $p['password'] = $p['password'] ?? false; - $p['dbname'] = $p['dbname'] ?? false; - $p['flags'] = $p['flags'] ?? 0; - $p['variables'] = $p['variables'] ?? []; - $p['tablePrefix'] = $p['tablePrefix'] ?? ''; - $p['schema'] = $p['schema'] ?? null; - $p['cliMode'] = $p['cliMode'] ?? ( PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg' ); - $p['agent'] = $p['agent'] ?? ''; - if ( !isset( $p['connLogger'] ) ) { - $p['connLogger'] = new NullLogger(); - } - if ( !isset( $p['queryLogger'] ) ) { - $p['queryLogger'] = new NullLogger(); - } - $p['profiler'] = $p['profiler'] ?? null; - if ( !isset( $p['trxProfiler'] ) ) { - $p['trxProfiler'] = new TransactionProfiler(); - } - if ( !isset( $p['errorLogger'] ) ) { - $p['errorLogger'] = function ( Exception $e ) { + $params += [ + 'host' => null, + 'user' => null, + 'password' => null, + 'dbname' => null, + 'schema' => null, + 'tablePrefix' => '', + 'flags' => 0, + 'variables' => [], + 'cliMode' => ( PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg' ), + 'agent' => basename( $_SERVER['SCRIPT_NAME'] ) . '@' . gethostname() + ]; + + $normalizedParams = [ + // Configuration + 'host' => strlen( $params['host'] ) ? $params['host'] : null, + 'user' => strlen( $params['user'] ) ? $params['user'] : null, + 'password' => is_string( $params['password'] ) ? $params['password'] : null, + 'dbname' => strlen( $params['dbname'] ) ? $params['dbname'] : null, + 'schema' => strlen( $params['schema'] ) ? $params['schema'] : null, + 'tablePrefix' => (string)$params['tablePrefix'], + 'flags' => (int)$params['flags'], + 'variables' => $params['variables'], + 'cliMode' => (bool)$params['cliMode'], + 'agent' => (string)$params['agent'], + // Objects and callbacks + 'profiler' => $params['profiler'] ?? null, + 'trxProfiler' => $params['trxProfiler'] ?? new TransactionProfiler(), + 'connLogger' => $params['connLogger'] ?? new NullLogger(), + 'queryLogger' => $params['queryLogger'] ?? new NullLogger(), + 'errorLogger' => $params['errorLogger'] ?? function ( Exception $e ) { trigger_error( get_class( $e ) . ': ' . $e->getMessage(), E_USER_WARNING ); - }; - } - if ( !isset( $p['deprecationLogger'] ) ) { - $p['deprecationLogger'] = function ( $msg ) { + }, + 'deprecationLogger' => $params['deprecationLogger'] ?? function ( $msg ) { trigger_error( $msg, E_USER_DEPRECATED ); - }; - } + } + ] + $params; /** @var Database $conn */ - $conn = new $class( $p ); - if ( $connect == self::NEW_CONNECTED ) { + $conn = new $class( $normalizedParams ); + if ( $connect === self::NEW_CONNECTED ) { $conn->initConnection(); } } else { @@ -586,12 +520,12 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware return $res; } - public function trxLevel() { - return $this->trxLevel; + final public function trxLevel() { + return ( $this->trxShortId != '' ) ? 1 : 0; } public function trxTimestamp() { - return $this->trxLevel ? $this->trxTimestamp : null; + return $this->trxLevel() ? $this->trxTimestamp : null; } /** @@ -617,7 +551,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware public function dbSchema( $schema = null ) { if ( strlen( $schema ) && $this->getDBname() === null ) { - throw new DBUnexpectedError( $this, "Cannot set schema to '$schema'; no database set." ); + throw new DBUnexpectedError( $this, "Cannot set schema to '$schema'; no database set" ); } $old = $this->currentDomain->getSchema(); @@ -652,11 +586,17 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware return null; } - public function setLBInfo( $name, $value = null ) { - if ( is_null( $value ) ) { - $this->lbInfo = $name; + public function setLBInfo( $nameOrArray, $value = null ) { + if ( is_array( $nameOrArray ) ) { + $this->lbInfo = $nameOrArray; + } elseif ( is_string( $nameOrArray ) ) { + if ( $value !== null ) { + $this->lbInfo[$nameOrArray] = $value; + } else { + unset( $this->lbInfo[$nameOrArray] ); + } } else { - $this->lbInfo[$name] = $value; + throw new InvalidArgumentException( "Got non-string key" ); } } @@ -694,20 +634,21 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } public function writesPending() { - return $this->trxLevel && $this->trxDoneWrites; + return $this->trxLevel() && $this->trxDoneWrites; } public function writesOrCallbacksPending() { - return $this->trxLevel && ( + return $this->trxLevel() && ( $this->trxDoneWrites || $this->trxIdleCallbacks || $this->trxPreCommitCallbacks || - $this->trxEndCallbacks + $this->trxEndCallbacks || + $this->trxSectionCancelCallbacks ); } public function preCommitCallbacksPending() { - return $this->trxLevel && $this->trxPreCommitCallbacks; + return $this->trxLevel() && $this->trxPreCommitCallbacks; } /** @@ -725,7 +666,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } public function pendingWriteQueryDuration( $type = self::ESTIMATE_TOTAL ) { - if ( !$this->trxLevel ) { + if ( !$this->trxLevel() ) { return false; } elseif ( !$this->trxDoneWrites ) { return 0.0; @@ -749,13 +690,13 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $applyTime = max( $this->trxWriteAdjDuration - $rttAdjTotal, 0 ); // For omitted queries, make them count as something at least $omitted = $this->trxWriteQueryCount - $this->trxWriteAdjQueryCount; - $applyTime += self::TINY_WRITE_SEC * $omitted; + $applyTime += self::$TINY_WRITE_SEC * $omitted; return $applyTime; } public function pendingWriteCallers() { - return $this->trxLevel ? $this->trxWriteCallers : []; + return $this->trxLevel() ? $this->trxWriteCallers : []; } public function pendingWriteRowsAffected() { @@ -775,7 +716,8 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware foreach ( [ $this->trxIdleCallbacks, $this->trxPreCommitCallbacks, - $this->trxEndCallbacks + $this->trxEndCallbacks, + $this->trxSectionCancelCallbacks ] as $callbacks ) { foreach ( $callbacks as $callback ) { $fnames[] = $callback[1]; @@ -795,12 +737,12 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } public function isOpen() { - return $this->opened; + return (bool)$this->conn; } public function setFlag( $flag, $remember = self::REMEMBER_NOTHING ) { if ( ( $flag & self::DBO_IGNORE ) ) { - throw new UnexpectedValueException( "Modifying DBO_IGNORE is not allowed." ); + throw new UnexpectedValueException( "Modifying DBO_IGNORE is not allowed" ); } if ( $remember === self::REMEMBER_PRIOR ) { @@ -811,7 +753,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware public function clearFlag( $flag, $remember = self::REMEMBER_NOTHING ) { if ( ( $flag & self::DBO_IGNORE ) ) { - throw new UnexpectedValueException( "Modifying DBO_IGNORE is not allowed." ); + throw new UnexpectedValueException( "Modifying DBO_IGNORE is not allowed" ); } if ( $remember === self::REMEMBER_PRIOR ) { @@ -939,17 +881,17 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware final public function close() { $exception = null; // error to throw after disconnecting - $wasOpen = $this->opened; + $wasOpen = (bool)$this->conn; // This should mostly do nothing if the connection is already closed if ( $this->conn ) { // Roll back any dangling transaction first - if ( $this->trxLevel ) { + if ( $this->trxLevel() ) { if ( $this->trxAtomicLevels ) { // Cannot let incomplete atomic sections be committed $levels = $this->flatAtomicSectionList(); $exception = new DBUnexpectedError( $this, - __METHOD__ . ": atomic sections $levels are still open." + __METHOD__ . ": atomic sections $levels are still open" ); } elseif ( $this->trxAutomatic ) { // Only the connection manager can commit non-empty DBO_TRX transactions @@ -958,7 +900,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $exception = new DBUnexpectedError( $this, __METHOD__ . - ": mass commit/rollback of peer transaction required (DBO_TRX set)." + ": mass commit/rollback of peer transaction required (DBO_TRX set)" ); } } else { @@ -966,14 +908,14 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware // back, even if empty. $exception = new DBUnexpectedError( $this, - __METHOD__ . ": transaction is still open (from {$this->trxFname})." + __METHOD__ . ": transaction is still open (from {$this->trxFname})" ); } if ( $this->trxEndCallbacksSuppressed ) { $exception = $exception ?: new DBUnexpectedError( $this, - __METHOD__ . ': callbacks are suppressed; cannot properly commit.' + __METHOD__ . ': callbacks are suppressed; cannot properly commit' ); } @@ -988,7 +930,6 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } $this->conn = false; - $this->opened = false; // Throw any unexpected errors after having disconnected if ( $exception instanceof Exception ) { @@ -1004,7 +945,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $fnames = $this->pendingWriteAndCallbackCallers(); if ( $fnames ) { throw new RuntimeException( - "Transaction callbacks are still pending:\n" . implode( ', ', $fnames ) + "Transaction callbacks are still pending: " . implode( ', ', $fnames ) ); } } @@ -1020,22 +961,23 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware * * @throws DBUnexpectedError */ - protected function assertHasConnectionHandle() { + final protected function assertHasConnectionHandle() { if ( !$this->isOpen() ) { - throw new DBUnexpectedError( $this, "DB connection was already closed." ); + throw new DBUnexpectedError( $this, "DB connection was already closed" ); } } /** * Make sure that this server is not marked as a replica nor read-only as a sanity check * - * @throws DBUnexpectedError + * @throws DBReadOnlyRoleError + * @throws DBReadOnlyError */ protected function assertIsWritableMaster() { if ( $this->getLBInfo( 'replica' ) === true ) { throw new DBReadOnlyRoleError( $this, - 'Write operations are not allowed on replica database connections.' + 'Write operations are not allowed on replica database connections' ); } $reason = $this->getReadOnlyReason(); @@ -1051,24 +993,25 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware */ abstract protected function closeConnection(); - /** - * @deprecated since 1.32 - * @param string $error Fallback message, if none is given by DB - * @throws DBConnectionError - */ - public function reportConnectionError( $error = 'Unknown error' ) { - call_user_func( $this->deprecationLogger, 'Use of ' . __METHOD__ . ' is deprecated.' ); - throw new DBConnectionError( $this, $this->lastError() ?: $error ); - } - /** * Run a query and return a DBMS-dependent wrapper or boolean * + * This is meant to handle the basic command of actually sending a query to the + * server via the driver. No implicit transaction, reconnection, nor retry logic + * should happen here. The higher level query() method is designed to handle those + * sorts of concerns. This method should not trigger such higher level methods. + * + * The lastError() and lastErrno() methods should meaningfully reflect what error, + * if any, occured during the last call to this method. Methods like executeQuery(), + * query(), select(), insert(), update(), delete(), and upsert() implement their calls + * to doQuery() such that an immediately subsequent call to lastError()/lastErrno() + * meaningfully reflects any error that occured during that public query method call. + * * For SELECT queries, this returns either: * - a) A driver-specific value/resource, only on success. This can be iterated * over by calling fetchObject()/fetchRow() until there are no more rows. - * Alternatively, the result can be passed to resultObject() to obtain a - * ResultWrapper instance which can then be iterated over via "foreach". + * Alternatively, the result can be passed to resultObject() to obtain an + * IResultWrapper instance which can then be iterated over via "foreach". * - b) False, on any query failure * * For non-SELECT queries, this returns either: @@ -1108,11 +1051,11 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware // for all queries within a request. Use cases: // - Treating these as writes would trigger ChronologyProtector (see method doc). // - We use this method to reject writes to replicas, but we need to allow - // use of transactions on replicas for read snapshots. This fine given + // use of transactions on replicas for read snapshots. This is fine given // that transactions by themselves don't make changes, only actual writes // within the transaction matter, which we still detect. return !preg_match( - '/^(?:SELECT|BEGIN|ROLLBACK|COMMIT|SAVEPOINT|RELEASE|SET|SHOW|EXPLAIN|\(SELECT)\b/i', + '/^(?:SELECT|BEGIN|ROLLBACK|COMMIT|SAVEPOINT|RELEASE|SET|SHOW|EXPLAIN|USE|\(SELECT)\b/i', $sql ); } @@ -1141,7 +1084,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware protected function isTransactableQuery( $sql ) { return !in_array( $this->getQueryVerb( $sql ), - [ 'BEGIN', 'ROLLBACK', 'COMMIT', 'SET', 'SHOW', 'CREATE', 'ALTER' ], + [ 'BEGIN', 'ROLLBACK', 'COMMIT', 'SET', 'SHOW', 'CREATE', 'ALTER', 'USE', 'SHOW' ], true ); } @@ -1149,9 +1092,12 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware /** * @param string $sql A SQL query * @param bool $pseudoPermanent Treat any table from CREATE TEMPORARY as pseudo-permanent - * @return int|null A self::TEMP_* constant for temp table operations or null otherwise + * @return array A n-tuple of: + * - int|null: A self::TEMP_* constant for temp table operations or null otherwise + * - string|null: The name of the new temporary table $sql creates, or null + * - string|null: The name of the temporary table that $sql drops, or null */ - protected function registerTempTableWrite( $sql, $pseudoPermanent ) { + protected function getTempWrites( $sql, $pseudoPermanent ) { static $qt = '[`"\']?(\w+)[`"\']?'; // quoted table if ( preg_match( @@ -1159,141 +1105,187 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $sql, $matches ) ) { - $type = $pseudoPermanent ? self::TEMP_PSEUDO_PERMANENT : self::TEMP_NORMAL; - $this->sessionTempTables[$matches[1]] = $type; + $type = $pseudoPermanent ? self::$TEMP_PSEUDO_PERMANENT : self::$TEMP_NORMAL; - return $type; + return [ $type, $matches[1], null ]; } elseif ( preg_match( '/^DROP\s+(?:TEMPORARY\s+)?TABLE\s+(?:IF\s+EXISTS\s+)?' . $qt . '/i', $sql, $matches ) ) { - $type = $this->sessionTempTables[$matches[1]] ?? null; - unset( $this->sessionTempTables[$matches[1]] ); - - return $type; + return [ $this->sessionTempTables[$matches[1]] ?? null, null, $matches[1] ]; } elseif ( preg_match( '/^TRUNCATE\s+(?:TEMPORARY\s+)?TABLE\s+(?:IF\s+EXISTS\s+)?' . $qt . '/i', $sql, $matches ) ) { - return $this->sessionTempTables[$matches[1]] ?? null; + return [ $this->sessionTempTables[$matches[1]] ?? null, null, null ]; } elseif ( preg_match( '/^(?:(?:INSERT|REPLACE)\s+(?:\w+\s+)?INTO|UPDATE|DELETE\s+FROM)\s+' . $qt . '/i', $sql, $matches ) ) { - return $this->sessionTempTables[$matches[1]] ?? null; + return [ $this->sessionTempTables[$matches[1]] ?? null, null, null ]; } - return null; + return [ null, null, null ]; } - public function query( $sql, $fname = __METHOD__, $flags = 0 ) { - $this->assertTransactionStatus( $sql, $fname ); - $this->assertHasConnectionHandle(); + /** + * @param IResultWrapper|bool $ret + * @param int|null $tmpType TEMP_NORMAL or TEMP_PSEUDO_PERMANENT + * @param string|null $tmpNew Name of created temp table + * @param string|null $tmpDel Name of dropped temp table + */ + protected function registerTempWrites( $ret, $tmpType, $tmpNew, $tmpDel ) { + if ( $ret !== false ) { + if ( $tmpNew !== null ) { + $this->sessionTempTables[$tmpNew] = $tmpType; + } + if ( $tmpDel !== null ) { + unset( $this->sessionTempTables[$tmpDel] ); + } + } + } + public function query( $sql, $fname = __METHOD__, $flags = 0 ) { $flags = (int)$flags; // b/c; this field used to be a bool - $ignoreErrors = $this->hasFlags( $flags, self::QUERY_SILENCE_ERRORS ); + // Sanity check that the SQL query is appropriate in the current context and is + // allowed for an outside caller (e.g. does not break transaction/session tracking). + $this->assertQueryIsCurrentlyAllowed( $sql, $fname ); - $priorTransaction = $this->trxLevel; - $priorWritesPending = $this->writesOrCallbacksPending(); + // Send the query to the server and fetch any corresponding errors + list( $ret, $err, $errno, $unignorable ) = $this->executeQuery( $sql, $fname, $flags ); + if ( $ret === false ) { + $ignoreErrors = $this->hasFlags( $flags, self::QUERY_SILENCE_ERRORS ); + // Throw an error unless both the ignore flag was set and a rollback is not needed + $this->reportQueryError( $err, $errno, $sql, $fname, $ignoreErrors && !$unignorable ); + } + + return $this->resultObject( $ret ); + } + + /** + * Execute a query, retrying it if there is a recoverable connection loss + * + * This is similar to query() except: + * - It does not prevent all non-ROLLBACK queries if there is a corrupted transaction + * - It does not disallow raw queries that are supposed to use dedicated IDatabase methods + * - It does not throw exceptions for common error cases + * + * This is meant for internal use with Database subclasses. + * + * @param string $sql Original SQL query + * @param string $fname Name of the calling function + * @param int $flags Bitfield of class QUERY_* constants + * @return array An n-tuple of: + * - mixed|bool: An object, resource, or true on success; false on failure + * - string: The result of calling lastError() + * - int: The result of calling lastErrno() + * - bool: Whether a rollback is needed to allow future non-rollback queries + * @throws DBUnexpectedError + */ + final protected function executeQuery( $sql, $fname, $flags ) { + $this->assertHasConnectionHandle(); + + $priorTransaction = $this->trxLevel(); if ( $this->isWriteQuery( $sql ) ) { - # In theory, non-persistent writes are allowed in read-only mode, but due to things - # like https://bugs.mysql.com/bug.php?id=33669 that might not work anyway... + // In theory, non-persistent writes are allowed in read-only mode, but due to things + // like https://bugs.mysql.com/bug.php?id=33669 that might not work anyway... $this->assertIsWritableMaster(); - # Do not treat temporary table writes as "meaningful writes" that need committing. - # Profile them as reads. Integration tests can override this behavior via $flags. + // Do not treat temporary table writes as "meaningful writes" since they are only + // visible to one session and are not permanent. Profile them as reads. Integration + // tests can override this behavior via $flags. $pseudoPermanent = $this->hasFlags( $flags, self::QUERY_PSEUDO_PERMANENT ); - $tableType = $this->registerTempTableWrite( $sql, $pseudoPermanent ); - $isEffectiveWrite = ( $tableType !== self::TEMP_NORMAL ); - # DBConnRef uses QUERY_REPLICA_ROLE to enforce the replica role for raw SQL queries - if ( $isEffectiveWrite && $this->hasFlags( $flags, self::QUERY_REPLICA_ROLE ) ) { + list( $tmpType, $tmpNew, $tmpDel ) = $this->getTempWrites( $sql, $pseudoPermanent ); + $isPermWrite = ( $tmpType !== self::$TEMP_NORMAL ); + // DBConnRef uses QUERY_REPLICA_ROLE to enforce the replica role for raw SQL queries + if ( $isPermWrite && $this->hasFlags( $flags, self::QUERY_REPLICA_ROLE ) ) { throw new DBReadOnlyRoleError( $this, "Cannot write; target role is DB_REPLICA" ); } } else { - $isEffectiveWrite = false; + // No permanent writes in this query + $isPermWrite = false; + // No temporary tables written to either + list( $tmpType, $tmpNew, $tmpDel ) = [ null, null, null ]; } - # Add trace comment to the begin of the sql string, right after the operator. - # Or, for one-word queries (like "BEGIN" or COMMIT") add it to the end (T44598) + // Add trace comment to the begin of the sql string, right after the operator. + // Or, for one-word queries (like "BEGIN" or COMMIT") add it to the end (T44598) $commentedSql = preg_replace( '/\s|$/', " /* $fname {$this->agent} */ ", $sql, 1 ); - # Send the query to the server and fetch any corresponding errors - $ret = $this->attemptQuery( $sql, $commentedSql, $isEffectiveWrite, $fname ); - $lastError = $this->lastError(); - $lastErrno = $this->lastErrno(); - - $recoverableSR = false; // recoverable statement rollback? - $recoverableCL = false; // recoverable connection loss? + // Send the query to the server and fetch any corresponding errors. + // This also doubles as a "ping" to see if the connection was dropped. + list( $ret, $err, $errno, $recoverableSR, $recoverableCL, $reconnected ) = + $this->executeQueryAttempt( $sql, $commentedSql, $isPermWrite, $fname, $flags ); - if ( $ret === false && $this->wasConnectionLoss() ) { - # Check if no meaningful session state was lost - $recoverableCL = $this->canRecoverFromDisconnect( $sql, $priorWritesPending ); - # Update session state tracking and try to restore the connection - $reconnected = $this->replaceLostConnection( __METHOD__ ); - # Silently resend the query to the server if it is safe and possible - if ( $recoverableCL && $reconnected ) { - $ret = $this->attemptQuery( $sql, $commentedSql, $isEffectiveWrite, $fname ); - $lastError = $this->lastError(); - $lastErrno = $this->lastErrno(); - - if ( $ret === false && $this->wasConnectionLoss() ) { - # Query probably causes disconnects; reconnect and do not re-run it - $this->replaceLostConnection( __METHOD__ ); - } else { - $recoverableCL = false; // connection does not need recovering - $recoverableSR = $this->wasKnownStatementRollbackError(); - } - } - } else { - $recoverableSR = $this->wasKnownStatementRollbackError(); + // Check if the query failed due to a recoverable connection loss + $allowRetry = !$this->hasFlags( $flags, self::QUERY_NO_RETRY ); + if ( $ret === false && $recoverableCL && $reconnected && $allowRetry ) { + // Silently resend the query to the server since it is safe and possible + list( $ret, $err, $errno, $recoverableSR, $recoverableCL ) = + $this->executeQueryAttempt( $sql, $commentedSql, $isPermWrite, $fname, $flags ); } + // Register creation and dropping of temporary tables + $this->registerTempWrites( $ret, $tmpType, $tmpNew, $tmpDel ); + + $corruptedTrx = false; + if ( $ret === false ) { if ( $priorTransaction ) { if ( $recoverableSR ) { # We're ignoring an error that caused just the current query to be aborted. # But log the cause so we can log a deprecation notice if a caller actually # does ignore it. - $this->trxStatusIgnoredCause = [ $lastError, $lastErrno, $fname ]; + $this->trxStatusIgnoredCause = [ $err, $errno, $fname ]; } elseif ( !$recoverableCL ) { # Either the query was aborted or all queries after BEGIN where aborted. # In the first case, the only options going forward are (a) ROLLBACK, or # (b) ROLLBACK TO SAVEPOINT (if one was set). If the later case, the only # option is ROLLBACK, since the snapshots would have been released. + $corruptedTrx = true; // cannot recover $this->trxStatus = self::STATUS_TRX_ERROR; $this->trxStatusCause = - $this->getQueryExceptionAndLog( $lastError, $lastErrno, $sql, $fname ); - $ignoreErrors = false; // cannot recover + $this->getQueryExceptionAndLog( $err, $errno, $sql, $fname ); $this->trxStatusIgnoredCause = null; } } - - $this->reportQueryError( $lastError, $lastErrno, $sql, $fname, $ignoreErrors ); } - return $this->resultObject( $ret ); + return [ $ret, $err, $errno, $corruptedTrx ]; } /** - * Wrapper for query() that also handles profiling, logging, and affected row count updates + * Wrapper for doQuery() that handles DBO_TRX, profiling, logging, affected row count + * tracking, and reconnects (without retry) on query failure due to connection loss * * @param string $sql Original SQL query * @param string $commentedSql SQL query with debugging/trace comment - * @param bool $isEffectiveWrite Whether the query is a (non-temporary table) write + * @param bool $isPermWrite Whether the query is a (non-temporary table) write * @param string $fname Name of the calling function - * @return bool|IResultWrapper True for a successful write query, ResultWrapper - * object for a successful read query, or false on failure + * @param int $flags Bitfield of class QUERY_* constants + * @return array An n-tuple of: + * - mixed|bool: An object, resource, or true on success; false on failure + * - string: The result of calling lastError() + * - int: The result of calling lastErrno() + * - bool: Whether a statement rollback error occured + * - bool: Whether a disconnect *both* happened *and* was recoverable + * - bool: Whether a reconnection attempt was *both* made *and* succeeded + * @throws DBUnexpectedError */ - private function attemptQuery( $sql, $commentedSql, $isEffectiveWrite, $fname ) { - $this->beginIfImplied( $sql, $fname ); + private function executeQueryAttempt( $sql, $commentedSql, $isPermWrite, $fname, $flags ) { + $priorWritesPending = $this->writesOrCallbacksPending(); + + if ( ( $flags & self::QUERY_IGNORE_DBO_TRX ) == 0 ) { + $this->beginIfImplied( $sql, $fname ); + } // Keep track of whether the transaction has write queries pending - if ( $isEffectiveWrite ) { + if ( $isPermWrite ) { $this->lastWriteTime = microtime( true ); - if ( $this->trxLevel && !$this->trxDoneWrites ) { + if ( $this->trxLevel() && !$this->trxDoneWrites ) { $this->trxDoneWrites = true; $this->trxProfiler->transactionWritingIn( $this->server, $this->getDomainID(), $this->trxShortId ); @@ -1310,27 +1302,42 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $this->affectedRowCount = null; $this->lastQuery = $sql; $ret = $this->doQuery( $commentedSql ); + $lastError = $this->lastError(); + $lastErrno = $this->lastErrno(); + $this->affectedRowCount = $this->affectedRows(); unset( $ps ); // profile out (if set) $queryRuntime = max( microtime( true ) - $startTime, 0.0 ); + $recoverableSR = false; // recoverable statement rollback? + $recoverableCL = false; // recoverable connection loss? + $reconnected = false; // reconnection both attempted and succeeded? + if ( $ret !== false ) { $this->lastPing = $startTime; - if ( $isEffectiveWrite && $this->trxLevel ) { + if ( $isPermWrite && $this->trxLevel() ) { $this->updateTrxWriteQueryTime( $sql, $queryRuntime, $this->affectedRows() ); $this->trxWriteCallers[] = $fname; } + } elseif ( $this->wasConnectionError( $lastErrno ) ) { + # Check if no meaningful session state was lost + $recoverableCL = $this->canRecoverFromDisconnect( $sql, $priorWritesPending ); + # Update session state tracking and try to restore the connection + $reconnected = $this->replaceLostConnection( __METHOD__ ); + } else { + # Check if only the last query was rolled back + $recoverableSR = $this->wasKnownStatementRollbackError(); } - if ( $sql === self::PING_QUERY ) { - $this->rttEstimate = $queryRuntime; + if ( $sql === self::$PING_QUERY ) { + $this->lastRoundTripEstimate = $queryRuntime; } $this->trxProfiler->recordQueryCompletion( $generalizedSql, $startTime, - $isEffectiveWrite, - $isEffectiveWrite ? $this->affectedRows() : $this->numRows( $ret ) + $isPermWrite, + $isPermWrite ? $this->affectedRows() : $this->numRows( $ret ) ); // Avoid the overhead of logging calls unless debug mode is enabled @@ -1346,7 +1353,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware ); } - return $ret; + return [ $ret, $lastError, $lastErrno, $recoverableSR, $recoverableCL, $reconnected ]; } /** @@ -1357,7 +1364,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware */ private function beginIfImplied( $sql, $fname ) { if ( - !$this->trxLevel && + !$this->trxLevel() && $this->getFlag( self::DBO_TRX ) && $this->isTransactableQuery( $sql ) ) { @@ -1381,13 +1388,13 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware private function updateTrxWriteQueryTime( $sql, $runtime, $affected ) { // Whether this is indicative of replica DB runtime (except for RBR or ws_repl) $indicativeOfReplicaRuntime = true; - if ( $runtime > self::SLOW_WRITE_SEC ) { + if ( $runtime > self::$SLOW_WRITE_SEC ) { $verb = $this->getQueryVerb( $sql ); // insert(), upsert(), replace() are fast unless bulky in size or blocked on locks if ( $verb === 'INSERT' ) { - $indicativeOfReplicaRuntime = $this->affectedRows() > self::SMALL_WRITE_ROWS; + $indicativeOfReplicaRuntime = $this->affectedRows() > self::$SMALL_WRITE_ROWS; } elseif ( $verb === 'REPLACE' ) { - $indicativeOfReplicaRuntime = $this->affectedRows() > self::SMALL_WRITE_ROWS / 2; + $indicativeOfReplicaRuntime = $this->affectedRows() > self::$SMALL_WRITE_ROWS / 2; } } @@ -1407,10 +1414,10 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware * @param string $fname * @throws DBTransactionStateError */ - private function assertTransactionStatus( $sql, $fname ) { + private function assertQueryIsCurrentlyAllowed( $sql, $fname ) { $verb = $this->getQueryVerb( $sql ); if ( $verb === 'USE' ) { - throw new DBUnexpectedError( $this, "Got USE query; use selectDomain() instead." ); + throw new DBUnexpectedError( $this, "Got USE query; use selectDomain() instead" ); } if ( $verb === 'ROLLBACK' ) { // transaction/savepoint @@ -1420,7 +1427,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware if ( $this->trxStatus < self::STATUS_TRX_OK ) { throw new DBTransactionStateError( $this, - "Cannot execute query from $fname while transaction status is ERROR.", + "Cannot execute query from $fname while transaction status is ERROR", [], $this->trxStatusCause ); @@ -1458,7 +1465,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware # Dropped connections also mean that named locks are automatically released. # Only allow error suppression in autocommit mode or when the lost transaction # didn't matter anyway (aside from DBO_TRX snapshot loss). - if ( $this->namedLocksHeld ) { + if ( $this->sessionNamedLocks ) { return false; // possible critical section violation } elseif ( $this->sessionTempTables ) { return false; // tables might be queried latter @@ -1485,9 +1492,9 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $this->sessionTempTables = []; // https://dev.mysql.com/doc/refman/5.7/en/miscellaneous-functions.html#function_get-lock // https://www.postgresql.org/docs/9.4/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS - $this->namedLocksHeld = []; + $this->sessionNamedLocks = []; // Session loss implies transaction loss - $this->trxLevel = 0; + $oldTrxShortId = $this->consumeTrxShortId(); $this->trxAtomicCounter = 0; $this->trxIdleCallbacks = []; // T67263; transaction already lost $this->trxPreCommitCallbacks = []; // T67263; transaction already lost @@ -1496,7 +1503,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $this->trxProfiler->transactionWritingOut( $this->server, $this->getDomainID(), - $this->trxShortId, + $oldTrxShortId, $this->pendingWriteQueryDuration( self::ESTIMATE_TOTAL ), $this->trxWriteAffectedRows ); @@ -1522,6 +1529,18 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } } + /** + * Reset the transaction ID and return the old one + * + * @return string The old transaction ID or the empty string if there wasn't one + */ + private function consumeTrxShortId() { + $old = $this->trxShortId; + $this->trxShortId = ''; + + return $old; + } + /** * Checks whether the cause of the error is detected to be a timeout. * @@ -1549,7 +1568,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware */ public function reportQueryError( $error, $errno, $sql, $fname, $ignore = false ) { if ( $ignore ) { - $this->queryLogger->debug( "SQL ERROR (ignored): $error\n" ); + $this->queryLogger->debug( "SQL ERROR (ignored): $error" ); } else { $exception = $this->getQueryExceptionAndLog( $error, $errno, $sql, $fname ); @@ -1577,7 +1596,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware 'trace' => ( new RuntimeException() )->getTraceAsString() ] ) ); - $this->queryLogger->debug( "SQL ERROR: " . $error . "\n" ); + $this->queryLogger->debug( "SQL ERROR: " . $error . "" ); if ( $this->wasQueryTimeout( $error, $errno ) ) { $e = new DBQueryTimeoutError( $this, $error, $errno, $sql, $fname ); } elseif ( $this->wasConnectionError( $errno ) ) { @@ -1810,7 +1829,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware // functions. Discourage use of such queries to encourage compatibility. call_user_func( $this->deprecationLogger, - __METHOD__ . ": aggregation used with a locking SELECT ($fname)." + __METHOD__ . ": aggregation used with a locking SELECT ($fname)" ); } @@ -2007,7 +2026,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } elseif ( count( $var ) == 1 ) { $column = $var[0] ?? reset( $var ); } else { - throw new DBUnexpectedError( $this, __METHOD__ . ': got multiple columns.' ); + throw new DBUnexpectedError( $this, __METHOD__ . ': got multiple columns' ); } } else { $column = $var; @@ -2019,7 +2038,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware public function lockForUpdate( $table, $conds = '', $fname = __METHOD__, $options = [], $join_conds = [] ) { - if ( !$this->trxLevel && !$this->getFlag( self::DBO_TRX ) ) { + if ( !$this->trxLevel() && !$this->getFlag( self::DBO_TRX ) ) { throw new DBUnexpectedError( $this, __METHOD__ . ': no transaction is active nor is DBO_TRX set' @@ -2377,7 +2396,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware if ( $name instanceof Subquery ) { throw new DBUnexpectedError( $this, - __METHOD__ . ': got Subquery instance when expecting a string.' + __METHOD__ . ': got Subquery instance when expecting a string' ); } @@ -2398,7 +2417,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware # surrounded by symbols which may be considered word breaks. if ( preg_match( '/(^|\s)(DISTINCT|JOIN|ON|AS)(\s|$)/i', $name ) !== 0 ) { $this->queryLogger->warning( - __METHOD__ . ": use of subqueries is not supported this way.", + __METHOD__ . ": use of subqueries is not supported this way", [ 'trace' => ( new RuntimeException() )->getTraceAsString() ] ); @@ -2521,12 +2540,12 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } elseif ( $table instanceof Subquery ) { $quotedTable = (string)$table; } else { - throw new InvalidArgumentException( "Table must be a string or Subquery." ); + throw new InvalidArgumentException( "Table must be a string or Subquery" ); } if ( $alias === false || $alias === $table ) { if ( $table instanceof Subquery ) { - throw new InvalidArgumentException( "Subquery table missing alias." ); + throw new InvalidArgumentException( "Subquery table missing alias" ); } return $quotedTable; @@ -2741,11 +2760,11 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $s ); } - public function buildLike() { - $params = func_get_args(); - - if ( count( $params ) > 0 && is_array( $params[0] ) ) { - $params = $params[0]; + public function buildLike( $param, ...$params ) { + if ( is_array( $param ) ) { + $params = $param; + } else { + $params = func_get_args(); } $s = ''; @@ -2963,7 +2982,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware public function textFieldSize( $table, $field ) { $table = $this->tableName( $table ); - $sql = "SHOW COLUMNS FROM $table LIKE \"$field\";"; + $sql = "SHOW COLUMNS FROM $table LIKE \"$field\""; $res = $this->query( $sql, __METHOD__ ); $row = $this->fetchObject( $res ); @@ -3159,8 +3178,10 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware public function limitResult( $sql, $limit, $offset = false ) { if ( !is_numeric( $limit ) ) { - throw new DBUnexpectedError( $this, - "Invalid non-numeric limit passed to limitResult()\n" ); + throw new DBUnexpectedError( + $this, + "Invalid non-numeric limit passed to " . __METHOD__ + ); } // This version works in MySQL and SQLite. It will very likely need to be // overridden for most other RDBMS subclasses. @@ -3313,7 +3334,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware public function deadlockLoop() { $args = func_get_args(); $function = array_shift( $args ); - $tries = self::DEADLOCK_TRIES; + $tries = self::$DEADLOCK_TRIES; $this->begin( __METHOD__ ); @@ -3327,7 +3348,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } catch ( DBQueryError $e ) { if ( $this->wasDeadlock() ) { // Retry after a randomized delay - usleep( mt_rand( self::DEADLOCK_DELAY_MIN, self::DEADLOCK_DELAY_MAX ) ); + usleep( mt_rand( self::$DEADLOCK_DELAY_MIN, self::$DEADLOCK_DELAY_MAX ) ); } else { // Throw the error back up throw $e; @@ -3366,21 +3387,21 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } final public function onTransactionResolution( callable $callback, $fname = __METHOD__ ) { - if ( !$this->trxLevel ) { - throw new DBUnexpectedError( $this, "No transaction is active." ); + if ( !$this->trxLevel() ) { + throw new DBUnexpectedError( $this, "No transaction is active" ); } $this->trxEndCallbacks[] = [ $callback, $fname, $this->currentAtomicSectionId() ]; } final public function onTransactionCommitOrIdle( callable $callback, $fname = __METHOD__ ) { - if ( !$this->trxLevel && $this->getTransactionRoundId() ) { + if ( !$this->trxLevel() && $this->getTransactionRoundId() ) { // Start an implicit transaction similar to how query() does $this->begin( __METHOD__, self::TRANSACTION_INTERNAL ); $this->trxAutomatic = true; } $this->trxIdleCallbacks[] = [ $callback, $fname, $this->currentAtomicSectionId() ]; - if ( !$this->trxLevel ) { + if ( !$this->trxLevel() ) { $this->runOnTransactionIdleCallbacks( self::TRIGGER_IDLE ); } } @@ -3390,13 +3411,13 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } final public function onTransactionPreCommitOrIdle( callable $callback, $fname = __METHOD__ ) { - if ( !$this->trxLevel && $this->getTransactionRoundId() ) { + if ( !$this->trxLevel() && $this->getTransactionRoundId() ) { // Start an implicit transaction similar to how query() does $this->begin( __METHOD__, self::TRANSACTION_INTERNAL ); $this->trxAutomatic = true; } - if ( $this->trxLevel ) { + if ( $this->trxLevel() ) { $this->trxPreCommitCallbacks[] = [ $callback, $fname, $this->currentAtomicSectionId() ]; } else { // No transaction is active nor will start implicitly, so make one for this callback @@ -3411,11 +3432,18 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } } + final public function onAtomicSectionCancel( callable $callback, $fname = __METHOD__ ) { + if ( !$this->trxLevel() || !$this->trxAtomicLevels ) { + throw new DBUnexpectedError( $this, "No atomic section is open (got $fname)" ); + } + $this->trxSectionCancelCallbacks[] = [ $callback, $fname, $this->currentAtomicSectionId() ]; + } + /** * @return AtomicSectionIdentifier|null ID of the topmost atomic section level */ private function currentAtomicSectionId() { - if ( $this->trxLevel && $this->trxAtomicLevels ) { + if ( $this->trxLevel() && $this->trxAtomicLevels ) { $levelInfo = end( $this->trxAtomicLevels ); return $levelInfo[1]; @@ -3425,6 +3453,8 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } /** + * Hoist callback ownership for callbacks in a section to a parent section. + * All callbacks should have an owner that is present in trxAtomicLevels. * @param AtomicSectionIdentifier $old * @param AtomicSectionIdentifier $new */ @@ -3446,13 +3476,35 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $this->trxEndCallbacks[$key][2] = $new; } } + foreach ( $this->trxSectionCancelCallbacks as $key => $info ) { + if ( $info[2] === $old ) { + $this->trxSectionCancelCallbacks[$key][2] = $new; + } + } } /** + * Update callbacks that were owned by cancelled atomic sections. + * + * Callbacks for "on commit" should never be run if they're owned by a + * section that won't be committed. + * + * Callbacks for "on resolution" need to reflect that the section was + * rolled back, even if the transaction as a whole commits successfully. + * + * Callbacks for "on section cancel" should already have been consumed, + * but errors during the cancellation itself can prevent that while still + * destroying the section. Hoist any such callbacks to the new top section, + * which we assume will itself have to be cancelled or rolled back to + * resolve the error. + * * @param AtomicSectionIdentifier[] $sectionIds ID of an actual savepoint + * @param AtomicSectionIdentifier|null $newSectionId New top section ID. * @throws UnexpectedValueException */ - private function modifyCallbacksForCancel( array $sectionIds ) { + private function modifyCallbacksForCancel( + array $sectionIds, AtomicSectionIdentifier $newSectionId = null + ) { // Cancel the "on commit" callbacks owned by this savepoint $this->trxIdleCallbacks = array_filter( $this->trxIdleCallbacks, @@ -3471,8 +3523,17 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware if ( in_array( $entry[2], $sectionIds, true ) ) { $callback = $entry[0]; $this->trxEndCallbacks[$key][0] = function () use ( $callback ) { + // @phan-suppress-next-line PhanInfiniteRecursion No recursion at all here, phan is confused return $callback( self::TRIGGER_ROLLBACK, $this ); }; + // This "on resolution" callback no longer belongs to a section. + $this->trxEndCallbacks[$key][2] = null; + } + } + // Hoist callback ownership for section cancel callbacks to the new top section + foreach ( $this->trxSectionCancelCallbacks as $key => $entry ) { + if ( in_array( $entry[2], $sectionIds, true ) ) { + $this->trxSectionCancelCallbacks[$key][2] = $newSectionId; } } } @@ -3508,8 +3569,8 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware * @throws Exception */ public function runOnTransactionIdleCallbacks( $trigger ) { - if ( $this->trxLevel ) { // sanity - throw new DBUnexpectedError( $this, __METHOD__ . ': a transaction is still open.' ); + if ( $this->trxLevel() ) { // sanity + throw new DBUnexpectedError( $this, __METHOD__ . ': a transaction is still open' ); } if ( $this->trxEndCallbacksSuppressed ) { @@ -3527,6 +3588,14 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware ); $this->trxIdleCallbacks = []; // consumed (and recursion guard) $this->trxEndCallbacks = []; // consumed (recursion guard) + + // Only run trxSectionCancelCallbacks on rollback, not commit. + // But always consume them. + if ( $trigger === self::TRIGGER_ROLLBACK ) { + $callbacks = array_merge( $callbacks, $this->trxSectionCancelCallbacks ); + } + $this->trxSectionCancelCallbacks = []; // consumed (recursion guard) + foreach ( $callbacks as $callback ) { ++$count; list( $phpCallback ) = $callback; @@ -3594,6 +3663,46 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware return $count; } + /** + * Actually run any "atomic section cancel" callbacks. + * + * @param int $trigger IDatabase::TRIGGER_* constant + * @param AtomicSectionIdentifier[]|null $sectionIds Section IDs to cancel, + * null on transaction rollback + */ + private function runOnAtomicSectionCancelCallbacks( + $trigger, array $sectionIds = null + ) { + /** @var Exception|Throwable $e */ + $e = null; // first exception + + $notCancelled = []; + do { + $callbacks = $this->trxSectionCancelCallbacks; + $this->trxSectionCancelCallbacks = []; // consumed (recursion guard) + foreach ( $callbacks as $entry ) { + if ( $sectionIds === null || in_array( $entry[2], $sectionIds, true ) ) { + try { + $entry[0]( $trigger, $this ); + } catch ( Exception $ex ) { + ( $this->errorLogger )( $ex ); + $e = $e ?: $ex; + } catch ( Throwable $ex ) { + // @todo: Log? + $e = $e ?: $ex; + } + } else { + $notCancelled[] = $entry; + } + } + } while ( count( $this->trxSectionCancelCallbacks ) ); + $this->trxSectionCancelCallbacks = $notCancelled; + + if ( $e !== null ) { + throw $e; // re-throw any first Exception/Throwable + } + } + /** * Actually run any "transaction listener" callbacks. * @@ -3691,7 +3800,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware ) { $savepointId = $cancelable === self::ATOMIC_CANCELABLE ? self::$NOT_APPLICABLE : null; - if ( !$this->trxLevel ) { + if ( !$this->trxLevel() ) { $this->begin( $fname, self::TRANSACTION_INTERNAL ); // sets trxAutomatic // If DBO_TRX is set, a series of startAtomic/endAtomic pairs will result // in all changes being in one transaction to keep requests transactional. @@ -3717,8 +3826,8 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } final public function endAtomic( $fname = __METHOD__ ) { - if ( !$this->trxLevel || !$this->trxAtomicLevels ) { - throw new DBUnexpectedError( $this, "No atomic section is open (got $fname)." ); + if ( !$this->trxLevel() || !$this->trxAtomicLevels ) { + throw new DBUnexpectedError( $this, "No atomic section is open (got $fname)" ); } // Check if the current section matches $fname @@ -3729,7 +3838,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware if ( $savedFname !== $fname ) { throw new DBUnexpectedError( $this, - "Invalid atomic section ended (got $fname but expected $savedFname)." + "Invalid atomic section ended (got $fname but expected $savedFname)" ); } @@ -3753,71 +3862,83 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware final public function cancelAtomic( $fname = __METHOD__, AtomicSectionIdentifier $sectionId = null ) { - if ( !$this->trxLevel || !$this->trxAtomicLevels ) { - throw new DBUnexpectedError( $this, "No atomic section is open (got $fname)." ); + if ( !$this->trxLevel() || !$this->trxAtomicLevels ) { + throw new DBUnexpectedError( $this, "No atomic section is open (got $fname)" ); } - $excisedFnames = []; - if ( $sectionId !== null ) { - // Find the (last) section with the given $sectionId - $pos = -1; - foreach ( $this->trxAtomicLevels as $i => list( $asFname, $asId, $spId ) ) { - if ( $asId === $sectionId ) { - $pos = $i; + $excisedIds = []; + $newTopSection = $this->currentAtomicSectionId(); + try { + $excisedFnames = []; + if ( $sectionId !== null ) { + // Find the (last) section with the given $sectionId + $pos = -1; + foreach ( $this->trxAtomicLevels as $i => list( $asFname, $asId, $spId ) ) { + if ( $asId === $sectionId ) { + $pos = $i; + } } + if ( $pos < 0 ) { + throw new DBUnexpectedError( $this, "Atomic section not found (for $fname)" ); + } + // Remove all descendant sections and re-index the array + $len = count( $this->trxAtomicLevels ); + for ( $i = $pos + 1; $i < $len; ++$i ) { + $excisedFnames[] = $this->trxAtomicLevels[$i][0]; + $excisedIds[] = $this->trxAtomicLevels[$i][1]; + } + $this->trxAtomicLevels = array_slice( $this->trxAtomicLevels, 0, $pos + 1 ); + $newTopSection = $this->currentAtomicSectionId(); } - if ( $pos < 0 ) { - throw new DBUnexpectedError( $this, "Atomic section not found (for $fname)" ); - } - // Remove all descendant sections and re-index the array - $excisedIds = []; - $len = count( $this->trxAtomicLevels ); - for ( $i = $pos + 1; $i < $len; ++$i ) { - $excisedFnames[] = $this->trxAtomicLevels[$i][0]; - $excisedIds[] = $this->trxAtomicLevels[$i][1]; - } - $this->trxAtomicLevels = array_slice( $this->trxAtomicLevels, 0, $pos + 1 ); - $this->modifyCallbacksForCancel( $excisedIds ); - } - // Check if the current section matches $fname - $pos = count( $this->trxAtomicLevels ) - 1; - list( $savedFname, $savedSectionId, $savepointId ) = $this->trxAtomicLevels[$pos]; + // Check if the current section matches $fname + $pos = count( $this->trxAtomicLevels ) - 1; + list( $savedFname, $savedSectionId, $savepointId ) = $this->trxAtomicLevels[$pos]; - if ( $excisedFnames ) { - $this->queryLogger->debug( "cancelAtomic: canceling level $pos ($savedFname) " . - "and descendants " . implode( ', ', $excisedFnames ) ); - } else { - $this->queryLogger->debug( "cancelAtomic: canceling level $pos ($savedFname)" ); - } + if ( $excisedFnames ) { + $this->queryLogger->debug( "cancelAtomic: canceling level $pos ($savedFname) " . + "and descendants " . implode( ', ', $excisedFnames ) ); + } else { + $this->queryLogger->debug( "cancelAtomic: canceling level $pos ($savedFname)" ); + } - if ( $savedFname !== $fname ) { - throw new DBUnexpectedError( - $this, - "Invalid atomic section ended (got $fname but expected $savedFname)." - ); - } + if ( $savedFname !== $fname ) { + throw new DBUnexpectedError( + $this, + "Invalid atomic section ended (got $fname but expected $savedFname)" + ); + } - // Remove the last section (no need to re-index the array) - array_pop( $this->trxAtomicLevels ); - $this->modifyCallbacksForCancel( [ $savedSectionId ] ); + // Remove the last section (no need to re-index the array) + array_pop( $this->trxAtomicLevels ); + $excisedIds[] = $savedSectionId; + $newTopSection = $this->currentAtomicSectionId(); - if ( $savepointId !== null ) { - // Rollback the transaction to the state just before this atomic section - if ( $savepointId === self::$NOT_APPLICABLE ) { - $this->rollback( $fname, self::FLUSHING_INTERNAL ); - } else { - $this->doRollbackToSavepoint( $savepointId, $fname ); - $this->trxStatus = self::STATUS_TRX_OK; // no exception; recovered - $this->trxStatusIgnoredCause = null; + if ( $savepointId !== null ) { + // Rollback the transaction to the state just before this atomic section + if ( $savepointId === self::$NOT_APPLICABLE ) { + $this->rollback( $fname, self::FLUSHING_INTERNAL ); + // Note: rollback() will run trxSectionCancelCallbacks + } else { + $this->doRollbackToSavepoint( $savepointId, $fname ); + $this->trxStatus = self::STATUS_TRX_OK; // no exception; recovered + $this->trxStatusIgnoredCause = null; + + // Run trxSectionCancelCallbacks now. + $this->runOnAtomicSectionCancelCallbacks( self::TRIGGER_CANCEL, $excisedIds ); + } + } elseif ( $this->trxStatus > self::STATUS_TRX_ERROR ) { + // Put the transaction into an error state if it's not already in one + $this->trxStatus = self::STATUS_TRX_ERROR; + $this->trxStatusCause = new DBUnexpectedError( + $this, + "Uncancelable atomic section canceled (got $fname)" + ); } - } elseif ( $this->trxStatus > self::STATUS_TRX_ERROR ) { - // Put the transaction into an error state if it's not already in one - $this->trxStatus = self::STATUS_TRX_ERROR; - $this->trxStatusCause = new DBUnexpectedError( - $this, - "Uncancelable atomic section canceled (got $fname)." - ); + } finally { + // Fix up callbacks owned by the sections that were just cancelled. + // All callbacks should have an owner that is present in trxAtomicLevels. + $this->modifyCallbacksForCancel( $excisedIds, $newTopSection ); } $this->affectedRowCount = 0; // for the sake of consistency @@ -3842,30 +3963,31 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware final public function begin( $fname = __METHOD__, $mode = self::TRANSACTION_EXPLICIT ) { static $modes = [ self::TRANSACTION_EXPLICIT, self::TRANSACTION_INTERNAL ]; if ( !in_array( $mode, $modes, true ) ) { - throw new DBUnexpectedError( $this, "$fname: invalid mode parameter '$mode'." ); + throw new DBUnexpectedError( $this, "$fname: invalid mode parameter '$mode'" ); } // Protect against mismatched atomic section, transaction nesting, and snapshot loss - if ( $this->trxLevel ) { + if ( $this->trxLevel() ) { if ( $this->trxAtomicLevels ) { $levels = $this->flatAtomicSectionList(); - $msg = "$fname: Got explicit BEGIN while atomic section(s) $levels are open."; + $msg = "$fname: Got explicit BEGIN while atomic section(s) $levels are open"; throw new DBUnexpectedError( $this, $msg ); } elseif ( !$this->trxAutomatic ) { - $msg = "$fname: Explicit transaction already active (from {$this->trxFname})."; + $msg = "$fname: Explicit transaction already active (from {$this->trxFname})"; throw new DBUnexpectedError( $this, $msg ); } else { - $msg = "$fname: Implicit transaction already active (from {$this->trxFname})."; + $msg = "$fname: Implicit transaction already active (from {$this->trxFname})"; throw new DBUnexpectedError( $this, $msg ); } } elseif ( $this->getFlag( self::DBO_TRX ) && $mode !== self::TRANSACTION_INTERNAL ) { - $msg = "$fname: Implicit transaction expected (DBO_TRX set)."; + $msg = "$fname: Implicit transaction expected (DBO_TRX set)"; throw new DBUnexpectedError( $this, $msg ); } $this->assertHasConnectionHandle(); $this->doBegin( $fname ); + $this->trxShortId = sprintf( '%06x', mt_rand( 0, 0xffffff ) ); $this->trxStatus = self::STATUS_TRX_OK; $this->trxStatusIgnoredCause = null; $this->trxAtomicCounter = 0; @@ -3874,7 +3996,6 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $this->trxDoneWrites = false; $this->trxAutomaticAtomic = false; $this->trxAtomicLevels = []; - $this->trxShortId = sprintf( '%06x', mt_rand( 0, 0xffffff ) ); $this->trxWriteDuration = 0.0; $this->trxWriteQueryCount = 0; $this->trxWriteAffectedRows = 0; @@ -3896,44 +4017,44 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware * * @see Database::begin() * @param string $fname + * @throws DBError */ protected function doBegin( $fname ) { $this->query( 'BEGIN', $fname ); - $this->trxLevel = 1; } final public function commit( $fname = __METHOD__, $flush = self::FLUSHING_ONE ) { static $modes = [ self::FLUSHING_ONE, self::FLUSHING_ALL_PEERS, self::FLUSHING_INTERNAL ]; if ( !in_array( $flush, $modes, true ) ) { - throw new DBUnexpectedError( $this, "$fname: invalid flush parameter '$flush'." ); + throw new DBUnexpectedError( $this, "$fname: invalid flush parameter '$flush'" ); } - if ( $this->trxLevel && $this->trxAtomicLevels ) { + if ( $this->trxLevel() && $this->trxAtomicLevels ) { // There are still atomic sections open; this cannot be ignored $levels = $this->flatAtomicSectionList(); throw new DBUnexpectedError( $this, - "$fname: Got COMMIT while atomic sections $levels are still open." + "$fname: Got COMMIT while atomic sections $levels are still open" ); } if ( $flush === self::FLUSHING_INTERNAL || $flush === self::FLUSHING_ALL_PEERS ) { - if ( !$this->trxLevel ) { + if ( !$this->trxLevel() ) { return; // nothing to do } elseif ( !$this->trxAutomatic ) { throw new DBUnexpectedError( $this, - "$fname: Flushing an explicit transaction, getting out of sync." + "$fname: Flushing an explicit transaction, getting out of sync" ); } - } elseif ( !$this->trxLevel ) { + } elseif ( !$this->trxLevel() ) { $this->queryLogger->error( - "$fname: No transaction to commit, something got out of sync." ); + "$fname: No transaction to commit, something got out of sync" ); return; // nothing to do } elseif ( $this->trxAutomatic ) { throw new DBUnexpectedError( $this, - "$fname: Expected mass commit of all peer transactions (DBO_TRX set)." + "$fname: Expected mass commit of all peer transactions (DBO_TRX set)" ); } @@ -3943,6 +4064,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $writeTime = $this->pendingWriteQueryDuration( self::ESTIMATE_DB_APPLY ); $this->doCommit( $fname ); + $oldTrxShortId = $this->consumeTrxShortId(); $this->trxStatus = self::STATUS_TRX_NONE; if ( $this->trxDoneWrites ) { @@ -3950,7 +4072,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $this->trxProfiler->transactionWritingOut( $this->server, $this->getDomainID(), - $this->trxShortId, + $oldTrxShortId, $writeTime, $this->trxWriteAffectedRows ); @@ -3968,16 +4090,16 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware * * @see Database::commit() * @param string $fname + * @throws DBError */ protected function doCommit( $fname ) { - if ( $this->trxLevel ) { + if ( $this->trxLevel() ) { $this->query( 'COMMIT', $fname ); - $this->trxLevel = 0; } } - final public function rollback( $fname = __METHOD__, $flush = '' ) { - $trxActive = $this->trxLevel; + final public function rollback( $fname = __METHOD__, $flush = self::FLUSHING_ONE ) { + $trxActive = $this->trxLevel(); if ( $flush !== self::FLUSHING_INTERNAL && $flush !== self::FLUSHING_ALL_PEERS @@ -3985,7 +4107,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware ) { throw new DBUnexpectedError( $this, - "$fname: Expected mass rollback of all peer transactions (DBO_TRX set)." + "$fname: Expected mass rollback of all peer transactions (DBO_TRX set)" ); } @@ -3993,6 +4115,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $this->assertHasConnectionHandle(); $this->doRollback( $fname ); + $oldTrxShortId = $this->consumeTrxShortId(); $this->trxStatus = self::STATUS_TRX_NONE; $this->trxAtomicLevels = []; // Estimate the RTT via a query now that trxStatus is OK @@ -4002,7 +4125,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $this->trxProfiler->transactionWritingOut( $this->server, $this->getDomainID(), - $this->trxShortId, + $oldTrxShortId, $writeTime, $this->trxWriteAffectedRows ); @@ -4036,23 +4159,42 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware * * @see Database::rollback() * @param string $fname + * @throws DBError */ protected function doRollback( $fname ) { - if ( $this->trxLevel ) { + if ( $this->trxLevel() ) { # Disconnects cause rollback anyway, so ignore those errors $ignoreErrors = true; $this->query( 'ROLLBACK', $fname, $ignoreErrors ); - $this->trxLevel = 0; } } - public function flushSnapshot( $fname = __METHOD__ ) { - if ( $this->writesOrCallbacksPending() || $this->explicitTrxActive() ) { + public function flushSnapshot( $fname = __METHOD__, $flush = self::FLUSHING_ONE ) { + if ( $this->explicitTrxActive() ) { + // Committing this transaction would break callers that assume it is still open + throw new DBUnexpectedError( + $this, + "$fname: Cannot flush snapshot; " . + "explicit transaction '{$this->trxFname}' is still open" + ); + } elseif ( $this->writesOrCallbacksPending() ) { // This only flushes transactions to clear snapshots, not to write data $fnames = implode( ', ', $this->pendingWriteAndCallbackCallers() ); throw new DBUnexpectedError( $this, - "$fname: Cannot flush snapshot because writes are pending ($fnames)." + "$fname: Cannot flush snapshot; " . + "writes from transaction {$this->trxFname} are still pending ($fnames)" + ); + } elseif ( + $this->trxLevel() && + $this->getTransactionRoundId() && + $flush !== self::FLUSHING_INTERNAL && + $flush !== self::FLUSHING_ALL_PEERS + ) { + $this->queryLogger->warning( + "$fname: Expected mass snapshot flush of all peer transactions " . + "in the explicit transactions round '{$this->getTransactionRoundId()}'", + [ 'trace' => ( new RuntimeException() )->getTraceAsString() ] ); } @@ -4060,7 +4202,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } public function explicitTrxActive() { - return $this->trxLevel && ( $this->trxAtomicLevels || !$this->trxAutomatic ); + return $this->trxLevel() && ( $this->trxAtomicLevels || !$this->trxAutomatic ); } public function duplicateTableStructure( @@ -4103,9 +4245,8 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware abstract protected function fetchAffectedRowCount(); /** - * Take the result from a query, and wrap it in a ResultWrapper if - * necessary. Boolean values are passed through as is, to indicate success - * of write queries or failure. + * Take a query result and wrap it in an iterable result wrapper if necessary. + * Booleans are passed through as-is to indicate success/failure of write queries. * * Once upon a time, Database::query() returned a bare MySQL result * resource, and it was necessary to call this function to convert it to @@ -4117,12 +4258,11 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware */ protected function resultObject( $result ) { if ( !$result ) { - return false; - } elseif ( $result instanceof ResultWrapper ) { + return false; // failed query + } elseif ( $result instanceof IResultWrapper ) { return $result; } elseif ( $result === true ) { - // Successful write query - return $result; + return $result; // succesful write query } else { return new ResultWrapper( $this, $result ); } @@ -4130,20 +4270,20 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware public function ping( &$rtt = null ) { // Avoid hitting the server if it was hit recently - if ( $this->isOpen() && ( microtime( true ) - $this->lastPing ) < self::PING_TTL ) { - if ( !func_num_args() || $this->rttEstimate > 0 ) { - $rtt = $this->rttEstimate; + if ( $this->isOpen() && ( microtime( true ) - $this->lastPing ) < self::$PING_TTL ) { + if ( !func_num_args() || $this->lastRoundTripEstimate > 0 ) { + $rtt = $this->lastRoundTripEstimate; return true; // don't care about $rtt } } // This will reconnect if possible or return false if not $this->clearFlag( self::DBO_TRX, self::REMEMBER_PRIOR ); - $ok = ( $this->query( self::PING_QUERY, __METHOD__, true ) !== false ); + $ok = ( $this->query( self::$PING_QUERY, __METHOD__, true ) !== false ); $this->restoreFlags( self::RESTORE_PRIOR ); if ( $ok ) { - $rtt = $this->rttEstimate; + $rtt = $this->lastRoundTripEstimate; } return $ok; @@ -4157,7 +4297,6 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware */ protected function replaceLostConnection( $fname ) { $this->closeConnection(); - $this->opened = false; $this->conn = false; $this->handleSessionLossPreconnect(); @@ -4167,8 +4306,8 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $this->server, $this->user, $this->password, - $this->getDBname(), - $this->dbSchema(), + $this->currentDomain->getDatabase(), + $this->currentDomain->getSchema(), $this->tablePrefix() ); $this->lastPing = microtime( true ); @@ -4213,7 +4352,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware * @since 1.27 */ final protected function getRecordedTransactionLagStatus() { - return ( $this->trxLevel && $this->trxReplicaLag !== null ) + return ( $this->trxLevel() && $this->trxReplicaLag !== null ) ? [ 'lag' => $this->trxReplicaLag, 'since' => $this->trxTimestamp() ] : null; } @@ -4268,6 +4407,16 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } public function getLag() { + if ( $this->getLBInfo( 'master' ) ) { + return 0; // this is the master + } elseif ( $this->getLBInfo( 'is static' ) ) { + return 0; // static dataset + } + + return $this->doGetLag(); + } + + protected function doGetLag() { return 0; } @@ -4296,12 +4445,12 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $fname = false, callable $inputCallback = null ) { - Wikimedia\suppressWarnings(); + AtEase::suppressWarnings(); $fp = fopen( $filename, 'r' ); - Wikimedia\restoreWarnings(); + AtEase::restoreWarnings(); if ( $fp === false ) { - throw new RuntimeException( "Could not open \"{$filename}\".\n" ); + throw new RuntimeException( "Could not open \"{$filename}\"" ); } if ( !$fname ) { @@ -4497,17 +4646,17 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware // RDBMs methods for checking named locks may or may not count this thread itself. // In MySQL, IS_FREE_LOCK() returns 0 if the thread already has the lock. This is // the behavior choosen by the interface for this method. - return !isset( $this->namedLocksHeld[$lockName] ); + return !isset( $this->sessionNamedLocks[$lockName] ); } public function lock( $lockName, $method, $timeout = 5 ) { - $this->namedLocksHeld[$lockName] = 1; + $this->sessionNamedLocks[$lockName] = 1; return true; } public function unlock( $lockName, $method ) { - unset( $this->namedLocksHeld[$lockName] ); + unset( $this->sessionNamedLocks[$lockName] ); return true; } @@ -4518,7 +4667,8 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $fnames = implode( ', ', $this->pendingWriteAndCallbackCallers() ); throw new DBUnexpectedError( $this, - "$fname: Cannot flush pre-lock snapshot because writes are pending ($fnames)." + "$fname: Cannot flush pre-lock snapshot; " . + "writes from transaction {$this->trxFname} are still pending ($fnames)" ); } @@ -4557,7 +4707,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware final public function lockTables( array $read, array $write, $method ) { if ( $this->writesOrCallbacksPending() ) { - throw new DBUnexpectedError( $this, "Transaction writes or callbacks still pending." ); + throw new DBUnexpectedError( $this, "Transaction writes or callbacks still pending" ); } if ( $this->tableLocksHaveTransactionScope() ) { @@ -4646,8 +4796,13 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware */ protected function getReadOnlyReason() { $reason = $this->getLBInfo( 'readOnlyReason' ); + if ( is_string( $reason ) ) { + return $reason; + } elseif ( $this->getLBInfo( 'replica' ) ) { + return "Server is configured in the role of a read-only replica database."; + } - return is_string( $reason ) ? $reason : false; + return false; } public function setTableAliases( array $aliases ) { @@ -4682,19 +4837,31 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware if ( !$this->conn ) { throw new DBUnexpectedError( $this, - 'DB connection was already closed or the connection dropped.' + 'DB connection was already closed or the connection dropped' ); } return $this->conn; } - /** - * @since 1.19 - * @return string - */ public function __toString() { - return (string)$this->conn; + // spl_object_id is PHP >= 7.2 + $id = function_exists( 'spl_object_id' ) + ? spl_object_id( $this ) + : spl_object_hash( $this ); + + $description = $this->getType() . ' object #' . $id; + if ( is_resource( $this->conn ) ) { + $description .= ' (' . (string)$this->conn . ')'; // "resource id #" + } elseif ( is_object( $this->conn ) ) { + // spl_object_id is PHP >= 7.2 + $handleId = function_exists( 'spl_object_id' ) + ? spl_object_id( $this->conn ) + : spl_object_hash( $this->conn ); + $description .= " (handle id #$handleId)"; + } + + return $description; } /** @@ -4703,22 +4870,22 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware */ public function __clone() { $this->connLogger->warning( - "Cloning " . static::class . " is not recommended; forking connection:\n" . - ( new RuntimeException() )->getTraceAsString() + "Cloning " . static::class . " is not recommended; forking connection", + [ 'trace' => ( new RuntimeException() )->getTraceAsString() ] ); if ( $this->isOpen() ) { // Open a new connection resource without messing with the old one - $this->opened = false; $this->conn = false; $this->trxEndCallbacks = []; // don't copy + $this->trxSectionCancelCallbacks = []; // don't copy $this->handleSessionLossPreconnect(); // no trx or locks anymore $this->open( $this->server, $this->user, $this->password, - $this->getDBname(), - $this->dbSchema(), + $this->currentDomain->getDatabase(), + $this->currentDomain->getSchema(), $this->tablePrefix() ); $this->lastPing = microtime( true ); @@ -4732,31 +4899,30 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware */ public function __sleep() { throw new RuntimeException( 'Database serialization may cause problems, since ' . - 'the connection is not restored on wakeup.' ); + 'the connection is not restored on wakeup' ); } /** * Run a few simple sanity checks and close dangling connections */ public function __destruct() { - if ( $this->trxLevel && $this->trxDoneWrites ) { - trigger_error( "Uncommitted DB writes (transaction from {$this->trxFname})." ); + if ( $this->trxLevel() && $this->trxDoneWrites ) { + trigger_error( "Uncommitted DB writes (transaction from {$this->trxFname})" ); } $danglingWriters = $this->pendingWriteAndCallbackCallers(); if ( $danglingWriters ) { $fnames = implode( ', ', $danglingWriters ); - trigger_error( "DB transaction writes or callbacks still pending ($fnames)." ); + trigger_error( "DB transaction writes or callbacks still pending ($fnames)" ); } if ( $this->conn ) { // Avoid connection leaks for sanity. Normally, resources close at script completion. // The connection might already be closed in zend/hhvm by now, so suppress warnings. - Wikimedia\suppressWarnings(); + AtEase::suppressWarnings(); $this->closeConnection(); - Wikimedia\restoreWarnings(); - $this->conn = false; - $this->opened = false; + AtEase::restoreWarnings(); + $this->conn = null; } } } diff --git a/includes/libs/rdbms/database/DatabaseMssql.php b/includes/libs/rdbms/database/DatabaseMssql.php index a532ec2810..d06bcb9274 100644 --- a/includes/libs/rdbms/database/DatabaseMssql.php +++ b/includes/libs/rdbms/database/DatabaseMssql.php @@ -27,9 +27,10 @@ namespace Wikimedia\Rdbms; -use Wikimedia; use Exception; +use RuntimeException; use stdClass; +use Wikimedia\AtEase\AtEase; /** * @ingroup Database @@ -78,7 +79,7 @@ class DatabaseMssql extends Database { } protected function open( $server, $user, $password, $dbName, $schema, $tablePrefix ) { - # Test for driver support, to avoid suppressed fatal error + // Test for driver support, to avoid suppressed fatal error if ( !function_exists( 'sqlsrv_connect' ) ) { throw new DBConnectionError( $this, @@ -87,11 +88,6 @@ class DatabaseMssql extends Database { ); } - # e.g. the class is being loaded - if ( !strlen( $user ) ) { - return null; - } - $this->close(); $this->server = $server; $this->user = $user; @@ -110,15 +106,19 @@ class DatabaseMssql extends Database { $connectionInfo['PWD'] = $password; } - Wikimedia\suppressWarnings(); + AtEase::suppressWarnings(); $this->conn = sqlsrv_connect( $server, $connectionInfo ); - Wikimedia\restoreWarnings(); + AtEase::restoreWarnings(); if ( $this->conn === false ) { - throw new DBConnectionError( $this, $this->lastError() ); + $error = $this->lastError(); + $this->connLogger->error( + "Error connecting to {db_server}: {error}", + $this->getLogContext( [ 'method' => __METHOD__, 'error' => $error ] ) + ); + throw new DBConnectionError( $this, $error ); } - $this->opened = true; $this->currentDomain = new DatabaseDomain( ( $dbName != '' ) ? $dbName : null, null, @@ -229,11 +229,7 @@ class DatabaseMssql extends Database { } public function freeResult( $res ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } - - sqlsrv_free_stmt( $res ); + sqlsrv_free_stmt( ResultWrapper::unwrap( $res ) ); } /** @@ -258,12 +254,9 @@ class DatabaseMssql extends Database { * @return int */ public function numRows( $res ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } + $res = ResultWrapper::unwrap( $res ); $ret = sqlsrv_num_rows( $res ); - if ( $ret === false ) { // we cannot get an amount of rows from this cursor type // has_rows returns bool true/false if the result has rows @@ -278,11 +271,7 @@ class DatabaseMssql extends Database { * @return int */ public function numFields( $res ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } - - return sqlsrv_num_fields( $res ); + return sqlsrv_num_fields( ResultWrapper::unwrap( $res ) ); } /** @@ -291,11 +280,7 @@ class DatabaseMssql extends Database { * @return int */ public function fieldName( $res, $n ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } - - return sqlsrv_field_metadata( $res )[$n]['Name']; + return sqlsrv_field_metadata( ResultWrapper::unwrap( $res ) )[$n]['Name']; } /** @@ -375,6 +360,17 @@ class DatabaseMssql extends Database { return $statementOnly; } + public function serverIsReadOnly() { + $encDatabase = $this->addQuotes( $this->getDBname() ); + $res = $this->query( + "SELECT IS_READ_ONLY FROM SYS.DATABASES WHERE NAME = $encDatabase", + __METHOD__ + ); + $row = $this->fetchObject( $res ); + + return $row ? (bool)$row->IS_READ_ONLY : false; + } + /** * @return int */ @@ -719,7 +715,7 @@ class DatabaseMssql extends Database { } $this->scrollableCursor = true; - if ( $ret instanceof ResultWrapper && !is_null( $identity ) ) { + if ( $ret instanceof IResultWrapper && !is_null( $identity ) ) { // Then we want to get the identity column value we were assigned and save it off $row = $ret->fetchObject(); if ( is_object( $row ) ) { @@ -1072,13 +1068,10 @@ class DatabaseMssql extends Database { $this->query( 'ROLLBACK TRANSACTION ' . $this->addIdentifierQuotes( $identifier ), $fname ); } - /** - * Begin a transaction, committing any previously open transaction - * @param string $fname - */ protected function doBegin( $fname = __METHOD__ ) { - sqlsrv_begin_transaction( $this->conn ); - $this->trxLevel = 1; + if ( !sqlsrv_begin_transaction( $this->conn ) ) { + $this->reportQueryError( $this->lastError(), $this->lastErrno(), 'BEGIN', $fname ); + } } /** @@ -1086,8 +1079,9 @@ class DatabaseMssql extends Database { * @param string $fname */ protected function doCommit( $fname = __METHOD__ ) { - sqlsrv_commit( $this->conn ); - $this->trxLevel = 0; + if ( !sqlsrv_commit( $this->conn ) ) { + $this->reportQueryError( $this->lastError(), $this->lastErrno(), 'COMMIT', $fname ); + } } /** @@ -1096,8 +1090,17 @@ class DatabaseMssql extends Database { * @param string $fname */ protected function doRollback( $fname = __METHOD__ ) { - sqlsrv_rollback( $this->conn ); - $this->trxLevel = 0; + if ( !sqlsrv_rollback( $this->conn ) ) { + $this->queryLogger->error( + "{fname}\t{db_server}\t{errno}\t{error}\t", + $this->getLogContext( [ + 'errno' => $this->lastErrno(), + 'error' => $this->lastError(), + 'fname' => $fname, + 'trace' => ( new RuntimeException() )->getTraceAsString() + ] ) + ); + } } /** @@ -1162,15 +1165,21 @@ class DatabaseMssql extends Database { protected function doSelectDomain( DatabaseDomain $domain ) { if ( $domain->getSchema() !== null ) { - throw new DBExpectedError( $this, __CLASS__ . ": domain schemas are not supported." ); + throw new DBExpectedError( + $this, + __CLASS__ . ": domain '{$domain->getId()}' has a schema component" + ); } $database = $domain->getDatabase(); if ( $database !== $this->getDBname() ) { - $encDatabase = $this->addIdentifierQuotes( $database ); - $res = $this->doQuery( "USE $encDatabase" ); - if ( !$res ) { - throw new DBExpectedError( $this, "Could not select database '$database'." ); + $sql = 'USE ' . $this->addIdentifierQuotes( $database ); + list( $res, $err, $errno ) = + $this->executeQuery( $sql, __METHOD__, self::QUERY_IGNORE_DBO_TRX ); + + if ( $res === false ) { + $this->reportQueryError( $err, $errno, $sql, __METHOD__ ); + return false; // unreachable } } // Update that domain fields on success (no exception thrown) diff --git a/includes/libs/rdbms/database/DatabaseMysqlBase.php b/includes/libs/rdbms/database/DatabaseMysqlBase.php index 6d28717abf..1e3fa845a3 100644 --- a/includes/libs/rdbms/database/DatabaseMysqlBase.php +++ b/includes/libs/rdbms/database/DatabaseMysqlBase.php @@ -24,7 +24,7 @@ namespace Wikimedia\Rdbms; use DateTime; use DateTimeZone; -use Wikimedia; +use Wikimedia\AtEase\AtEase; use InvalidArgumentException; use Exception; use RuntimeException; @@ -96,7 +96,7 @@ abstract class DatabaseMysqlBase extends Database { * - sslCiphers : array list of allowable ciphers [default: null] * @param array $params */ - function __construct( array $params ) { + public function __construct( array $params ) { $this->lagDetectionMethod = $params['lagDetectionMethod'] ?? 'Seconds_Behind_Master'; $this->lagDetectionOptions = $params['lagDetectionOptions'] ?? []; $this->useGTIDs = !empty( $params['useGTIDs' ] ); @@ -122,9 +122,12 @@ abstract class DatabaseMysqlBase extends Database { } protected function open( $server, $user, $password, $dbName, $schema, $tablePrefix ) { - # Close/unset connection handle $this->close(); + if ( $schema !== null ) { + throw new DBExpectedError( $this, __CLASS__ . ": cannot use schemas ('$schema')" ); + } + $this->server = $server; $this->user = $user; $this->password = $password; @@ -143,91 +146,58 @@ abstract class DatabaseMysqlBase extends Database { $error = $error ?: $this->lastError(); $this->connLogger->error( "Error connecting to {db_server}: {error}", - $this->getLogContext( [ - 'method' => __METHOD__, - 'error' => $error, - ] ) + $this->getLogContext( [ 'method' => __METHOD__, 'error' => $error ] ) ); $this->connLogger->debug( "DB connection error\n" . "Server: $server, User: $user, Password: " . substr( $password, 0, 3 ) . "..., error: " . $error . "\n" ); - throw new DBConnectionError( $this, $error ); } - if ( strlen( $dbName ) ) { - $this->selectDomain( new DatabaseDomain( $dbName, null, $tablePrefix ) ); - } else { - $this->currentDomain = new DatabaseDomain( null, null, $tablePrefix ); - } - - // Tell the server what we're communicating with - if ( !$this->connectInitCharset() ) { - $error = $this->lastError(); - $this->queryLogger->error( - "Error setting character set: {error}", - $this->getLogContext( [ - 'method' => __METHOD__, - 'error' => $this->lastError(), - ] ) + try { + $this->currentDomain = new DatabaseDomain( + strlen( $dbName ) ? $dbName : null, + null, + $tablePrefix ); - throw new DBConnectionError( $this, "Error setting character set: $error" ); - } - // Abstract over any insane MySQL defaults - $set = [ 'group_concat_max_len = 262144' ]; - // Set SQL mode, default is turning them all off, can be overridden or skipped with null - if ( is_string( $this->sqlMode ) ) { - $set[] = 'sql_mode = ' . $this->addQuotes( $this->sqlMode ); - } - // Set any custom settings defined by site config - // (e.g. https://dev.mysql.com/doc/refman/4.1/en/innodb-parameters.html) - foreach ( $this->sessionVars as $var => $val ) { - // Escape strings but not numbers to avoid MySQL complaining - if ( !is_int( $val ) && !is_float( $val ) ) { - $val = $this->addQuotes( $val ); + // Abstract over any insane MySQL defaults + $set = [ 'group_concat_max_len = 262144' ]; + // Set SQL mode, default is turning them all off, can be overridden or skipped with null + if ( is_string( $this->sqlMode ) ) { + $set[] = 'sql_mode = ' . $this->addQuotes( $this->sqlMode ); + } + // Set any custom settings defined by site config + // (e.g. https://dev.mysql.com/doc/refman/4.1/en/innodb-parameters.html) + foreach ( $this->connectionVariables as $var => $val ) { + // Escape strings but not numbers to avoid MySQL complaining + if ( !is_int( $val ) && !is_float( $val ) ) { + $val = $this->addQuotes( $val ); + } + $set[] = $this->addIdentifierQuotes( $var ) . ' = ' . $val; } - $set[] = $this->addIdentifierQuotes( $var ) . ' = ' . $val; - } - if ( $set ) { - // Use doQuery() to avoid opening implicit transactions (DBO_TRX) - $success = $this->doQuery( 'SET ' . implode( ', ', $set ) ); - if ( !$success ) { - $error = $this->lastError(); - $this->queryLogger->error( - 'Error setting MySQL variables on server {db_server}: {error}', - $this->getLogContext( [ - 'method' => __METHOD__, - 'error' => $error, - ] ) + if ( $set ) { + $this->query( + 'SET ' . implode( ', ', $set ), + __METHOD__, + self::QUERY_IGNORE_DBO_TRX | self::QUERY_NO_RETRY ); - throw new DBConnectionError( $this, "Error setting MySQL variables: $error" ); } + } catch ( Exception $e ) { + // Connection was not fully initialized and is not safe for use + $this->conn = false; } - $this->opened = true; - return true; } - /** - * Set the character set information right after connection - * @return bool - */ - protected function connectInitCharset() { - if ( $this->utf8Mode ) { - // Tell the server we're communicating with it in UTF-8. - // This may engage various charset conversions. - return $this->mysqlSetCharset( 'utf8' ); - } else { - return $this->mysqlSetCharset( 'binary' ); - } - } - protected function doSelectDomain( DatabaseDomain $domain ) { if ( $domain->getSchema() !== null ) { - throw new DBExpectedError( $this, __CLASS__ . ": domain schemas are not supported." ); + throw new DBExpectedError( + $this, + __CLASS__ . ": domain '{$domain->getId()}' has a schema component" + ); } $database = $domain->getDatabase(); @@ -244,11 +214,12 @@ abstract class DatabaseMysqlBase extends Database { if ( $database !== $this->getDBname() ) { $sql = 'USE ' . $this->addIdentifierQuotes( $database ); - $ret = $this->doQuery( $sql ); - if ( $ret === false ) { - $error = $this->lastError(); - $errno = $this->lastErrno(); - $this->reportQueryError( $error, $errno, $sql, __METHOD__ ); + list( $res, $err, $errno ) = + $this->executeQuery( $sql, __METHOD__, self::QUERY_IGNORE_DBO_TRX ); + + if ( $res === false ) { + $this->reportQueryError( $err, $errno, $sql, __METHOD__ ); + return false; // unreachable } } @@ -268,25 +239,14 @@ abstract class DatabaseMysqlBase extends Database { */ abstract protected function mysqlConnect( $realServer, $dbName ); - /** - * Set the character set of the MySQL link - * - * @param string $charset - * @return bool - */ - abstract protected function mysqlSetCharset( $charset ); - /** * @param IResultWrapper|resource $res * @throws DBUnexpectedError */ public function freeResult( $res ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } - Wikimedia\suppressWarnings(); - $ok = $this->mysqlFreeResult( $res ); - Wikimedia\restoreWarnings(); + AtEase::suppressWarnings(); + $ok = $this->mysqlFreeResult( ResultWrapper::unwrap( $res ) ); + AtEase::restoreWarnings(); if ( !$ok ) { throw new DBUnexpectedError( $this, "Unable to free MySQL result" ); } @@ -306,12 +266,9 @@ abstract class DatabaseMysqlBase extends Database { * @throws DBUnexpectedError */ public function fetchObject( $res ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } - Wikimedia\suppressWarnings(); - $row = $this->mysqlFetchObject( $res ); - Wikimedia\restoreWarnings(); + AtEase::suppressWarnings(); + $row = $this->mysqlFetchObject( ResultWrapper::unwrap( $res ) ); + AtEase::restoreWarnings(); $errno = $this->lastErrno(); // Unfortunately, mysql_fetch_object does not reset the last errno. @@ -342,12 +299,9 @@ abstract class DatabaseMysqlBase extends Database { * @throws DBUnexpectedError */ public function fetchRow( $res ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } - Wikimedia\suppressWarnings(); - $row = $this->mysqlFetchArray( $res ); - Wikimedia\restoreWarnings(); + AtEase::suppressWarnings(); + $row = $this->mysqlFetchArray( ResultWrapper::unwrap( $res ) ); + AtEase::restoreWarnings(); $errno = $this->lastErrno(); // Unfortunately, mysql_fetch_array does not reset the last errno. @@ -368,7 +322,7 @@ abstract class DatabaseMysqlBase extends Database { * Fetch a result row as an associative and numeric array * * @param resource $res Raw result - * @return array + * @return array|false */ abstract protected function mysqlFetchArray( $res ); @@ -378,12 +332,13 @@ abstract class DatabaseMysqlBase extends Database { * @return int */ function numRows( $res ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; + if ( is_bool( $res ) ) { + $n = 0; + } else { + AtEase::suppressWarnings(); + $n = $this->mysqlNumRows( ResultWrapper::unwrap( $res ) ); + AtEase::restoreWarnings(); } - Wikimedia\suppressWarnings(); - $n = !is_bool( $res ) ? $this->mysqlNumRows( $res ) : 0; - Wikimedia\restoreWarnings(); // Unfortunately, mysql_num_rows does not reset the last errno. // We are not checking for any errors here, since @@ -406,11 +361,7 @@ abstract class DatabaseMysqlBase extends Database { * @return int */ public function numFields( $res ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } - - return $this->mysqlNumFields( $res ); + return $this->mysqlNumFields( ResultWrapper::unwrap( $res ) ); } /** @@ -427,11 +378,7 @@ abstract class DatabaseMysqlBase extends Database { * @return string */ public function fieldName( $res, $n ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } - - return $this->mysqlFieldName( $res, $n ); + return $this->mysqlFieldName( ResultWrapper::unwrap( $res ), $n ); } /** @@ -450,11 +397,7 @@ abstract class DatabaseMysqlBase extends Database { * @return string */ public function fieldType( $res, $n ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } - - return $this->mysqlFieldType( $res, $n ); + return $this->mysqlFieldType( ResultWrapper::unwrap( $res ), $n ); } /** @@ -472,11 +415,7 @@ abstract class DatabaseMysqlBase extends Database { * @return bool */ public function dataSeek( $res, $row ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } - - return $this->mysqlDataSeek( $res, $row ); + return $this->mysqlDataSeek( ResultWrapper::unwrap( $res ), $row ); } /** @@ -494,12 +433,12 @@ abstract class DatabaseMysqlBase extends Database { public function lastError() { if ( $this->conn ) { # Even if it's non-zero, it can still be invalid - Wikimedia\suppressWarnings(); + AtEase::suppressWarnings(); $error = $this->mysqlError( $this->conn ); if ( !$error ) { $error = $this->mysqlError(); } - Wikimedia\restoreWarnings(); + AtEase::restoreWarnings(); } else { $error = $this->mysqlError(); } @@ -641,13 +580,14 @@ abstract class DatabaseMysqlBase extends Database { */ public function fieldInfo( $table, $field ) { $table = $this->tableName( $table ); - $res = $this->query( "SELECT * FROM $table LIMIT 1", __METHOD__, true ); + $flags = self::QUERY_SILENCE_ERRORS; + $res = $this->query( "SELECT * FROM $table LIMIT 1", __METHOD__, $flags ); if ( !$res ) { return false; } - $n = $this->mysqlNumFields( $res->result ); + $n = $this->mysqlNumFields( ResultWrapper::unwrap( $res ) ); for ( $i = 0; $i < $n; $i++ ) { - $meta = $this->mysqlFetchField( $res->result, $i ); + $meta = $this->mysqlFetchField( ResultWrapper::unwrap( $res ), $i ); if ( $field == $meta->name ) { return new MySQLField( $meta ); } @@ -743,7 +683,7 @@ abstract class DatabaseMysqlBase extends Database { return strlen( $name ) && $name[0] == '`' && substr( $name, -1, 1 ) == '`'; } - public function getLag() { + protected function doGetLag() { if ( $this->getLagDetectionMethod() === 'pt-heartbeat' ) { return $this->getLagFromPtHeartbeat(); } else { @@ -762,7 +702,8 @@ abstract class DatabaseMysqlBase extends Database { * @return bool|int */ protected function getLagFromSlaveStatus() { - $res = $this->query( 'SHOW SLAVE STATUS', __METHOD__ ); + $flags = self::QUERY_SILENCE_ERRORS | self::QUERY_IGNORE_DBO_TRX; + $res = $this->query( 'SHOW SLAVE STATUS', __METHOD__, $flags ); $row = $res ? $res->fetchObject() : false; // If the server is not replicating, there will be no row if ( $row && strval( $row->Seconds_Behind_Master ) !== '' ) { @@ -864,7 +805,8 @@ abstract class DatabaseMysqlBase extends Database { // Connect to and query the master; catch errors to avoid outages try { - $res = $conn->query( 'SELECT @@server_id AS id', $fname ); + $flags = self::QUERY_SILENCE_ERRORS | self::QUERY_IGNORE_DBO_TRX; + $res = $conn->query( 'SELECT @@server_id AS id', $fname, $flags ); $row = $res ? $res->fetchObject() : false; $id = $row ? (int)$row->id : 0; } catch ( DBError $e ) { @@ -894,7 +836,8 @@ abstract class DatabaseMysqlBase extends Database { // percision field is not supported in MySQL <= 5.5. $res = $this->query( "SELECT ts FROM heartbeat.heartbeat WHERE $whereSQL ORDER BY ts DESC LIMIT 1", - __METHOD__ + __METHOD__, + self::QUERY_SILENCE_ERRORS | self::QUERY_IGNORE_DBO_TRX ); $row = $res ? $res->fetchObject() : false; } finally { @@ -952,21 +895,22 @@ abstract class DatabaseMysqlBase extends Database { $gtidArg = $this->addQuotes( implode( ',', $gtidsWait ) ); if ( strpos( $gtidArg, ':' ) !== false ) { // MySQL GTIDs, e.g "source_id:transaction_id" - $res = $this->doQuery( "SELECT WAIT_FOR_EXECUTED_GTID_SET($gtidArg, $timeout)" ); + $sql = "SELECT WAIT_FOR_EXECUTED_GTID_SET($gtidArg, $timeout)"; } else { // MariaDB GTIDs, e.g."domain:server:sequence" - $res = $this->doQuery( "SELECT MASTER_GTID_WAIT($gtidArg, $timeout)" ); + $sql = "SELECT MASTER_GTID_WAIT($gtidArg, $timeout)"; } } else { // Wait on the binlog coordinates $encFile = $this->addQuotes( $pos->getLogFile() ); $encPos = intval( $pos->getLogPosition()[$pos::CORD_EVENT] ); - $res = $this->doQuery( "SELECT MASTER_POS_WAIT($encFile, $encPos, $timeout)" ); + $sql = "SELECT MASTER_POS_WAIT($encFile, $encPos, $timeout)"; } + list( $res, $err ) = $this->executeQuery( $sql, __METHOD__, self::QUERY_IGNORE_DBO_TRX ); $row = $res ? $this->fetchRow( $res ) : false; if ( !$row ) { - throw new DBExpectedError( $this, "Replication wait failed: {$this->lastError()}" ); + throw new DBExpectedError( $this, "Replication wait failed: {$err}" ); } // Result can be NULL (error), -1 (timeout), or 0+ per the MySQL manual @@ -1071,7 +1015,9 @@ abstract class DatabaseMysqlBase extends Database { $this->srvCache->makeGlobalKey( 'mysql-server-id', $this->getServer() ), self::SERVER_ID_CACHE_TTL, function () use ( $fname ) { - $res = $this->query( "SELECT @@server_id AS id", $fname ); + $flags = self::QUERY_IGNORE_DBO_TRX; + $res = $this->query( "SELECT @@server_id AS id", $fname, $flags ); + return intval( $this->fetchObject( $res )->id ); } ); @@ -1081,11 +1027,13 @@ abstract class DatabaseMysqlBase extends Database { * @return string|null */ protected function getServerUUID() { + $fname = __METHOD__; return $this->srvCache->getWithSetCallback( $this->srvCache->makeGlobalKey( 'mysql-server-uuid', $this->getServer() ), self::SERVER_ID_CACHE_TTL, - function () { - $res = $this->query( "SHOW GLOBAL VARIABLES LIKE 'server_uuid'" ); + function () use ( $fname ) { + $flags = self::QUERY_IGNORE_DBO_TRX; + $res = $this->query( "SHOW GLOBAL VARIABLES LIKE 'server_uuid'", $fname, $flags ); $row = $this->fetchObject( $res ); return $row ? $row->Value : null; @@ -1099,13 +1047,15 @@ abstract class DatabaseMysqlBase extends Database { */ protected function getServerGTIDs( $fname = __METHOD__ ) { $map = []; + + $flags = self::QUERY_IGNORE_DBO_TRX; // Get global-only variables like gtid_executed - $res = $this->query( "SHOW GLOBAL VARIABLES LIKE 'gtid_%'", $fname ); + $res = $this->query( "SHOW GLOBAL VARIABLES LIKE 'gtid_%'", $fname, $flags ); foreach ( $res as $row ) { $map[$row->Variable_name] = $row->Value; } // Get session-specific (e.g. gtid_domain_id since that is were writes will log) - $res = $this->query( "SHOW SESSION VARIABLES LIKE 'gtid_%'", $fname ); + $res = $this->query( "SHOW SESSION VARIABLES LIKE 'gtid_%'", $fname, $flags ); foreach ( $res as $row ) { $map[$row->Variable_name] = $row->Value; } @@ -1119,11 +1069,14 @@ abstract class DatabaseMysqlBase extends Database { * @return string[] Latest available server status row */ protected function getServerRoleStatus( $role, $fname = __METHOD__ ) { - return $this->query( "SHOW $role STATUS", $fname )->fetchRow() ?: []; + $flags = self::QUERY_IGNORE_DBO_TRX; + + return $this->query( "SHOW $role STATUS", $fname, $flags )->fetchRow() ?: []; } public function serverIsReadOnly() { - $res = $this->query( "SHOW GLOBAL VARIABLES LIKE 'read_only'", __METHOD__ ); + $flags = self::QUERY_IGNORE_DBO_TRX; + $res = $this->query( "SHOW GLOBAL VARIABLES LIKE 'read_only'", __METHOD__, $flags ); $row = $this->fetchObject( $res ); return $row ? ( strtolower( $row->Value ) === 'on' ) : false; @@ -1188,9 +1141,10 @@ abstract class DatabaseMysqlBase extends Database { */ public function setSessionOptions( array $options ) { if ( isset( $options['connTimeout'] ) ) { + $flags = self::QUERY_IGNORE_DBO_TRX; $timeout = (int)$options['connTimeout']; - $this->query( "SET net_read_timeout=$timeout" ); - $this->query( "SET net_write_timeout=$timeout" ); + $this->query( "SET net_read_timeout=$timeout", __METHOD__, $flags ); + $this->query( "SET net_write_timeout=$timeout", __METHOD__, $flags ); } } @@ -1223,8 +1177,10 @@ abstract class DatabaseMysqlBase extends Database { } $encName = $this->addQuotes( $this->makeLockName( $lockName ) ); - $result = $this->query( "SELECT IS_FREE_LOCK($encName) AS lockstatus", $method ); - $row = $this->fetchObject( $result ); + + $flags = self::QUERY_IGNORE_DBO_TRX; + $res = $this->query( "SELECT IS_FREE_LOCK($encName) AS lockstatus", $method, $flags ); + $row = $this->fetchObject( $res ); return ( $row->lockstatus == 1 ); } @@ -1237,8 +1193,10 @@ abstract class DatabaseMysqlBase extends Database { */ public function lock( $lockName, $method, $timeout = 5 ) { $encName = $this->addQuotes( $this->makeLockName( $lockName ) ); - $result = $this->query( "SELECT GET_LOCK($encName, $timeout) AS lockstatus", $method ); - $row = $this->fetchObject( $result ); + + $flags = self::QUERY_IGNORE_DBO_TRX; + $res = $this->query( "SELECT GET_LOCK($encName, $timeout) AS lockstatus", $method, $flags ); + $row = $this->fetchObject( $res ); if ( $row->lockstatus == 1 ) { parent::lock( $lockName, $method, $timeout ); // record @@ -1260,8 +1218,10 @@ abstract class DatabaseMysqlBase extends Database { */ public function unlock( $lockName, $method ) { $encName = $this->addQuotes( $this->makeLockName( $lockName ) ); - $result = $this->query( "SELECT RELEASE_LOCK($encName) as lockstatus", $method ); - $row = $this->fetchObject( $result ); + + $flags = self::QUERY_IGNORE_DBO_TRX; + $res = $this->query( "SELECT RELEASE_LOCK($encName) as lockstatus", $method, $flags ); + $row = $this->fetchObject( $res ); if ( $row->lockstatus == 1 ) { parent::unlock( $lockName, $method ); // record @@ -1297,13 +1257,13 @@ abstract class DatabaseMysqlBase extends Database { } $sql = "LOCK TABLES " . implode( ',', $items ); - $this->query( $sql, $method ); + $this->query( $sql, $method, self::QUERY_IGNORE_DBO_TRX ); return true; } protected function doUnlockTables( $method ) { - $this->query( "UNLOCK TABLES", $method ); + $this->query( "UNLOCK TABLES", $method, self::QUERY_IGNORE_DBO_TRX ); return true; } @@ -1324,7 +1284,7 @@ abstract class DatabaseMysqlBase extends Database { (bool)$this->selectField( false, '@@sql_big_selects', '', __METHOD__ ); } $encValue = $value ? '1' : '0'; - $this->query( "SET sql_big_selects=$encValue", __METHOD__ ); + $this->query( "SET sql_big_selects=$encValue", __METHOD__, self::QUERY_IGNORE_DBO_TRX ); } /** @@ -1507,7 +1467,8 @@ abstract class DatabaseMysqlBase extends Database { * @return array */ private function getMysqlStatus( $which = "%" ) { - $res = $this->query( "SHOW STATUS LIKE '{$which}'" ); + $flags = self::QUERY_IGNORE_DBO_TRX; + $res = $this->query( "SHOW STATUS LIKE '{$which}'", __METHOD__, $flags ); $status = []; foreach ( $res as $row ) { diff --git a/includes/libs/rdbms/database/DatabaseMysqli.php b/includes/libs/rdbms/database/DatabaseMysqli.php index 1a5cdab78d..0f444cd210 100644 --- a/includes/libs/rdbms/database/DatabaseMysqli.php +++ b/includes/libs/rdbms/database/DatabaseMysqli.php @@ -125,21 +125,6 @@ class DatabaseMysqli extends DatabaseMysqlBase { return false; } - protected function connectInitCharset() { - // already done in mysqlConnect() - return true; - } - - /** - * @param string $charset - * @return bool - */ - protected function mysqlSetCharset( $charset ) { - $conn = $this->getBindingHandle(); - - return $conn->set_charset( $charset ); - } - /** * @return bool */ @@ -203,7 +188,7 @@ class DatabaseMysqli extends DatabaseMysqlBase { /** * @param mysqli_result $res - * @return bool + * @return array|false */ protected function mysqlFetchArray( $res ) { $array = $res->fetch_array(); @@ -307,21 +292,6 @@ class DatabaseMysqli extends DatabaseMysqlBase { return $conn->real_escape_string( (string)$s ); } - /** - * Give an id for the connection - * - * mysql driver used resource id, but mysqli objects cannot be cast to string. - * @return string - */ - public function __toString() { - if ( $this->conn instanceof mysqli ) { - return (string)$this->conn->thread_id; - } else { - // mConn might be false or something. - return (string)$this->conn; - } - } - /** * @return mysqli */ diff --git a/includes/libs/rdbms/database/DatabasePostgres.php b/includes/libs/rdbms/database/DatabasePostgres.php index d8be62f1f4..840b4280b6 100644 --- a/includes/libs/rdbms/database/DatabasePostgres.php +++ b/includes/libs/rdbms/database/DatabasePostgres.php @@ -24,7 +24,7 @@ namespace Wikimedia\Rdbms; use Wikimedia\Timestamp\ConvertibleTimestamp; use Wikimedia\WaitConditionLoop; -use Wikimedia; +use Wikimedia\AtEase\AtEase; use Exception; /** @@ -87,7 +87,7 @@ class DatabasePostgres extends Database { } protected function open( $server, $user, $password, $dbName, $schema, $tablePrefix ) { - # Test for Postgres support, to avoid suppressed fatal error + // Test for Postgres support, to avoid suppressed fatal error if ( !function_exists( 'pg_connect' ) ) { throw new DBConnectionError( $this, @@ -97,6 +97,8 @@ class DatabasePostgres extends Database { ); } + $this->close(); + $this->server = $server; $this->user = $user; $this->password = $password; @@ -120,9 +122,8 @@ class DatabasePostgres extends Database { } $this->connectString = $this->makeConnectionString( $connectVars ); - $this->close(); - $this->installErrorHandler(); + $this->installErrorHandler(); try { // Use new connections to let LoadBalancer/LBFactory handle reuse $this->conn = pg_connect( $this->connectString, PGSQL_CONNECT_FORCE_NEW ); @@ -130,7 +131,6 @@ class DatabasePostgres extends Database { $this->restoreErrorHandler(); throw $ex; } - $phpError = $this->restoreErrorHandler(); if ( !$this->conn ) { @@ -143,23 +143,36 @@ class DatabasePostgres extends Database { throw new DBConnectionError( $this, str_replace( "\n", ' ', $phpError ) ); } - $this->opened = true; + try { + // If called from the command-line (e.g. importDump), only show errors. + // No transaction should be open at this point, so the problem of the SET + // effects being rolled back should not be an issue. + // See https://www.postgresql.org/docs/8.3/sql-set.html + $variables = []; + if ( $this->cliMode ) { + $variables['client_min_messages'] = 'ERROR'; + } + $variables += [ + 'client_encoding' => 'UTF8', + 'datestyle' => 'ISO, YMD', + 'timezone' => 'GMT', + 'standard_conforming_strings' => 'on', + 'bytea_output' => 'escape' + ]; + foreach ( $variables as $var => $val ) { + $this->query( + 'SET ' . $this->addIdentifierQuotes( $var ) . ' = ' . $this->addQuotes( $val ), + __METHOD__, + self::QUERY_IGNORE_DBO_TRX | self::QUERY_NO_RETRY + ); + } - # If called from the command-line (e.g. importDump), only show errors - if ( $this->cliMode ) { - $this->doQuery( "SET client_min_messages = 'ERROR'" ); + $this->determineCoreSchema( $schema ); + $this->currentDomain = new DatabaseDomain( $dbName, $schema, $tablePrefix ); + } catch ( Exception $e ) { + // Connection was not fully initialized and is not safe for use + $this->conn = false; } - - $this->query( "SET client_encoding='UTF8'", __METHOD__ ); - $this->query( "SET datestyle = 'ISO, YMD'", __METHOD__ ); - $this->query( "SET timezone = 'GMT'", __METHOD__ ); - $this->query( "SET standard_conforming_strings = on", __METHOD__ ); - $this->query( "SET bytea_output = 'escape'", __METHOD__ ); // PHP bug 53127 - - $this->determineCoreSchema( $schema ); - $this->currentDomain = new DatabaseDomain( $dbName, $schema, $tablePrefix ); - - return (bool)$this->conn; } protected function relationSchemaQualifier() { @@ -261,24 +274,18 @@ class DatabasePostgres extends Database { } public function freeResult( $res ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } - Wikimedia\suppressWarnings(); - $ok = pg_free_result( $res ); - Wikimedia\restoreWarnings(); + AtEase::suppressWarnings(); + $ok = pg_free_result( ResultWrapper::unwrap( $res ) ); + AtEase::restoreWarnings(); if ( !$ok ) { throw new DBUnexpectedError( $this, "Unable to free Postgres result\n" ); } } public function fetchObject( $res ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } - Wikimedia\suppressWarnings(); - $row = pg_fetch_object( $res ); - Wikimedia\restoreWarnings(); + AtEase::suppressWarnings(); + $row = pg_fetch_object( ResultWrapper::unwrap( $res ) ); + AtEase::restoreWarnings(); # @todo FIXME: HACK HACK HACK HACK debug # @todo hashar: not sure if the following test really trigger if the object @@ -295,12 +302,9 @@ class DatabasePostgres extends Database { } public function fetchRow( $res ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } - Wikimedia\suppressWarnings(); - $row = pg_fetch_array( $res ); - Wikimedia\restoreWarnings(); + AtEase::suppressWarnings(); + $row = pg_fetch_array( ResultWrapper::unwrap( $res ) ); + AtEase::restoreWarnings(); $conn = $this->getBindingHandle(); if ( pg_last_error( $conn ) ) { @@ -318,12 +322,9 @@ class DatabasePostgres extends Database { return 0; } - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } - Wikimedia\suppressWarnings(); - $n = pg_num_rows( $res ); - Wikimedia\restoreWarnings(); + AtEase::suppressWarnings(); + $n = pg_num_rows( ResultWrapper::unwrap( $res ) ); + AtEase::restoreWarnings(); $conn = $this->getBindingHandle(); if ( pg_last_error( $conn ) ) { @@ -337,19 +338,11 @@ class DatabasePostgres extends Database { } public function numFields( $res ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } - - return pg_num_fields( $res ); + return pg_num_fields( ResultWrapper::unwrap( $res ) ); } public function fieldName( $res, $n ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } - - return pg_field_name( $res, $n ); + return pg_field_name( ResultWrapper::unwrap( $res ), $n ); } public function insertId() { @@ -359,11 +352,7 @@ class DatabasePostgres extends Database { } public function dataSeek( $res, $row ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } - - return pg_result_seek( $res, $row ); + return pg_result_seek( ResultWrapper::unwrap( $res ), $row ); } public function lastError() { @@ -895,9 +884,12 @@ __INDEXATTR__; } /** + * @param string $prefix Only show tables with this prefix, e.g. mw_ + * @param string $fname Calling function name + * @return string[] * @suppress SecurityCheck-SQLInjection array_map not recognized T204911 */ - public function listTables( $prefix = null, $fname = __METHOD__ ) { + public function listTables( $prefix = '', $fname = __METHOD__ ) { $eschemas = implode( ',', array_map( [ $this, 'addQuotes' ], $this->getCoreSchemas() ) ); $result = $this->query( "SELECT DISTINCT tablename FROM pg_tables WHERE schemaname IN ($eschemas)", $fname ); @@ -906,7 +898,7 @@ __INDEXATTR__; foreach ( $result as $table ) { $vars = get_object_vars( $table ); $table = array_pop( $vars ); - if ( !$prefix || strpos( $table, $prefix ) === 0 ) { + if ( $prefix == '' || strpos( $table, $prefix ) === 0 ) { $endArray[] = $table; } } @@ -981,7 +973,7 @@ __INDEXATTR__; * @return string Default schema for the current session */ public function getCurrentSchema() { - $res = $this->query( "SELECT current_schema()", __METHOD__ ); + $res = $this->query( "SELECT current_schema()", __METHOD__, self::QUERY_IGNORE_DBO_TRX ); $row = $this->fetchRow( $res ); return $row[0]; @@ -998,7 +990,11 @@ __INDEXATTR__; * @return array List of actual schemas for the current sesson */ public function getSchemas() { - $res = $this->query( "SELECT current_schemas(false)", __METHOD__ ); + $res = $this->query( + "SELECT current_schemas(false)", + __METHOD__, + self::QUERY_IGNORE_DBO_TRX + ); $row = $this->fetchRow( $res ); $schemas = []; @@ -1017,7 +1013,7 @@ __INDEXATTR__; * @return array How to search for table names schemas for the current user */ public function getSearchPath() { - $res = $this->query( "SHOW search_path", __METHOD__ ); + $res = $this->query( "SHOW search_path", __METHOD__, self::QUERY_IGNORE_DBO_TRX ); $row = $this->fetchRow( $res ); /* PostgreSQL returns SHOW values as strings */ @@ -1033,7 +1029,11 @@ __INDEXATTR__; * @param array $search_path List of schemas to be searched by default */ private function setSearchPath( $search_path ) { - $this->query( "SET search_path = " . implode( ", ", $search_path ) ); + $this->query( + "SET search_path = " . implode( ", ", $search_path ), + __METHOD__, + self::QUERY_IGNORE_DBO_TRX + ); } /** @@ -1051,7 +1051,15 @@ __INDEXATTR__; * @param string $desiredSchema */ public function determineCoreSchema( $desiredSchema ) { - $this->begin( __METHOD__, self::TRANSACTION_INTERNAL ); + if ( $this->trxLevel() ) { + // We do not want the schema selection to change on ROLLBACK or INSERT SELECT. + // See https://www.postgresql.org/docs/8.3/sql-set.html + throw new DBUnexpectedError( + $this, + __METHOD__ . ": a transaction is currently active" + ); + } + if ( $this->schemaExists( $desiredSchema ) ) { if ( in_array( $desiredSchema, $this->getSchemas() ) ) { $this->coreSchema = $desiredSchema; @@ -1064,8 +1072,7 @@ __INDEXATTR__; * Fixes T17816 */ $search_path = $this->getSearchPath(); - array_unshift( $search_path, - $this->addIdentifierQuotes( $desiredSchema ) ); + array_unshift( $search_path, $this->addIdentifierQuotes( $desiredSchema ) ); $this->setSearchPath( $search_path ); $this->coreSchema = $desiredSchema; $this->queryLogger->debug( @@ -1077,8 +1084,6 @@ __INDEXATTR__; "Schema \"" . $desiredSchema . "\" not found, using current \"" . $this->coreSchema . "\"\n" ); } - /* Commit SET otherwise it will be rollbacked on error or IGNORE SELECT */ - $this->commit( __METHOD__, self::FLUSHING_INTERNAL ); } /** @@ -1243,10 +1248,14 @@ SQL; return false; // short-circuit } - $exists = $this->selectField( - '"pg_catalog"."pg_namespace"', 1, [ 'nspname' => $schema ], __METHOD__ ); + $res = $this->query( + "SELECT 1 FROM pg_catalog.pg_namespace " . + "WHERE nspname = " . $this->addQuotes( $schema ) . " LIMIT 1", + __METHOD__, + self::QUERY_IGNORE_DBO_TRX + ); - return (bool)$exists; + return ( $this->numRows( $res ) > 0 ); } /** @@ -1277,11 +1286,7 @@ SQL; * @return string */ public function fieldType( $res, $index ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } - - return pg_field_type( $res, $index ); + return pg_field_type( ResultWrapper::unwrap( $res ), $index ); } public function encodeBlob( $b ) { diff --git a/includes/libs/rdbms/database/DatabaseSqlite.php b/includes/libs/rdbms/database/DatabaseSqlite.php index 8e1b06d6e6..11dda2fb39 100644 --- a/includes/libs/rdbms/database/DatabaseSqlite.php +++ b/includes/libs/rdbms/database/DatabaseSqlite.php @@ -23,12 +23,12 @@ */ namespace Wikimedia\Rdbms; +use NullLockManager; use PDO; use PDOException; use Exception; use LockManager; use FSLockManager; -use InvalidArgumentException; use RuntimeException; use stdClass; @@ -36,12 +36,9 @@ use stdClass; * @ingroup Database */ class DatabaseSqlite extends Database { - /** @var bool Whether full text is enabled */ - private static $fulltextEnabled = null; - - /** @var string Directory */ + /** @var string|null Directory for SQLite database files listed under their DB name */ protected $dbDir; - /** @var string File name for SQLite database file */ + /** @var string|null Explicit path for the SQLite database file */ protected $dbPath; /** @var string Transaction mode */ protected $trxMode; @@ -60,43 +57,40 @@ class DatabaseSqlite extends Database { /** @var array List of shared database already attached to this connection */ private $alreadyAttached = []; + /** @var bool Whether full text is enabled */ + private static $fulltextEnabled = null; + /** * Additional params include: * - dbDirectory : directory containing the DB and the lock file directory - * [defaults to $wgSQLiteDataDir] * - dbFilePath : use this to force the path of the DB file * - trxMode : one of (deferred, immediate, exclusive) * @param array $p */ - function __construct( array $p ) { + public function __construct( array $p ) { if ( isset( $p['dbFilePath'] ) ) { $this->dbPath = $p['dbFilePath']; - $lockDomain = md5( $this->dbPath ); - // Use "X" for things like X.sqlite and ":memory:" for RAM-only DBs - if ( !isset( $p['dbname'] ) || !strlen( $p['dbname'] ) ) { - $p['dbname'] = preg_replace( '/\.sqlite\d?$/', '', basename( $this->dbPath ) ); + if ( !strlen( $p['dbname'] ) ) { + $p['dbname'] = self::generateDatabaseName( $this->dbPath ); } } elseif ( isset( $p['dbDirectory'] ) ) { $this->dbDir = $p['dbDirectory']; - $lockDomain = $p['dbname']; - } else { - throw new InvalidArgumentException( "Need 'dbDirectory' or 'dbFilePath' parameter." ); } - $this->trxMode = isset( $p['trxMode'] ) ? strtoupper( $p['trxMode'] ) : null; - if ( $this->trxMode && - !in_array( $this->trxMode, [ 'DEFERRED', 'IMMEDIATE', 'EXCLUSIVE' ] ) - ) { - $this->trxMode = null; - $this->queryLogger->warning( "Invalid SQLite transaction mode provided." ); - } + // Set a dummy user to make initConnection() trigger open() + parent::__construct( [ 'user' => '@' ] + $p ); - $this->lockMgr = new FSLockManager( [ - 'domain' => $lockDomain, - 'lockDirectory' => "{$this->dbDir}/locks" - ] ); + $this->trxMode = strtoupper( $p['trxMode'] ?? '' ); - parent::__construct( $p ); + $lockDirectory = $this->getLockFileDirectory(); + if ( $lockDirectory !== null ) { + $this->lockMgr = new FSLockManager( [ + 'domain' => $this->getDomainID(), + 'lockDirectory' => $lockDirectory + ] ); + } else { + $this->lockMgr = new NullLockManager( [ 'domain' => $this->getDomainID() ] ); + } } protected static function getAttributes() { @@ -122,38 +116,10 @@ class DatabaseSqlite extends Database { return $db; } - protected function doInitConnection() { - if ( $this->dbPath !== null ) { - // Standalone .sqlite file mode. - $this->openFile( - $this->dbPath, - $this->connectionParams['dbname'], - $this->connectionParams['tablePrefix'] - ); - } elseif ( $this->dbDir !== null ) { - // Stock wiki mode using standard file names per DB - if ( strlen( $this->connectionParams['dbname'] ) ) { - $this->open( - $this->connectionParams['host'], - $this->connectionParams['user'], - $this->connectionParams['password'], - $this->connectionParams['dbname'], - $this->connectionParams['schema'], - $this->connectionParams['tablePrefix'] - ); - } else { - // Caller will manually call open() later? - $this->connLogger->debug( __METHOD__ . ': no database opened.' ); - } - } else { - throw new InvalidArgumentException( "Need 'dbDirectory' or 'dbFilePath' parameter." ); - } - } - /** * @return string */ - function getType() { + public function getType() { return 'sqlite'; } @@ -162,77 +128,100 @@ class DatabaseSqlite extends Database { * * @return bool */ - function implicitGroupby() { + public function implicitGroupby() { return false; } protected function open( $server, $user, $pass, $dbName, $schema, $tablePrefix ) { $this->close(); - $fileName = self::generateFileName( $this->dbDir, $dbName ); - if ( !is_readable( $fileName ) ) { - $this->conn = false; - throw new DBConnectionError( $this, "SQLite database not accessible" ); + + // Note that for SQLite, $server, $user, and $pass are ignored + + if ( $schema !== null ) { + throw new DBExpectedError( $this, __CLASS__ . ": cannot use schemas ('$schema')" ); } - // Only $dbName is used, the other parameters are irrelevant for SQLite databases - $this->openFile( $fileName, $dbName, $tablePrefix ); - return (bool)$this->conn; - } + if ( $this->dbPath !== null ) { + $path = $this->dbPath; + } elseif ( $this->dbDir !== null ) { + $path = self::generateFileName( $this->dbDir, $dbName ); + } else { + throw new DBExpectedError( $this, __CLASS__ . ": DB path or directory required" ); + } - /** - * Opens a database file - * - * @param string $fileName - * @param string $dbName - * @param string $tablePrefix - * @throws DBConnectionError - * @return PDO|bool SQL connection or false if failed - */ - protected function openFile( $fileName, $dbName, $tablePrefix ) { - $err = false; + if ( !in_array( $this->trxMode, [ '', 'DEFERRED', 'IMMEDIATE', 'EXCLUSIVE' ], true ) ) { + throw new DBExpectedError( + $this, + __CLASS__ . ": invalid transaction mode '{$this->trxMode}'" + ); + } + + if ( !self::isProcessMemoryPath( $path ) && !is_readable( $path ) ) { + $error = "SQLite database file not readable"; + $this->connLogger->error( + "Error connecting to {db_server}: {error}", + $this->getLogContext( [ 'method' => __METHOD__, 'error' => $error ] ) + ); + throw new DBConnectionError( $this, $error ); + } - $this->dbPath = $fileName; try { - if ( $this->flags & self::DBO_PERSISTENT ) { - $this->conn = new PDO( "sqlite:$fileName", '', '', - [ PDO::ATTR_PERSISTENT => true ] ); - } else { - $this->conn = new PDO( "sqlite:$fileName", '', '' ); - } + $conn = new PDO( + "sqlite:$path", + '', + '', + [ PDO::ATTR_PERSISTENT => (bool)( $this->flags & self::DBO_PERSISTENT ) ] + ); + // Set error codes only, don't raise exceptions + $conn->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_SILENT ); } catch ( PDOException $e ) { - $err = $e->getMessage(); + $error = $e->getMessage(); + $this->connLogger->error( + "Error connecting to {db_server}: {error}", + $this->getLogContext( [ 'method' => __METHOD__, 'error' => $error ] ) + ); + throw new DBConnectionError( $this, $error ); } - if ( !$this->conn ) { - $this->queryLogger->debug( "DB connection error: $err\n" ); - throw new DBConnectionError( $this, $err ); - } + $this->conn = $conn; + $this->currentDomain = new DatabaseDomain( $dbName, null, $tablePrefix ); - $this->opened = is_object( $this->conn ); - if ( $this->opened ) { - $this->currentDomain = new DatabaseDomain( $dbName, null, $tablePrefix ); - # Set error codes only, don't raise exceptions - $this->conn->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_SILENT ); - # Enforce LIKE to be case sensitive, just like MySQL - $this->query( 'PRAGMA case_sensitive_like = 1' ); - - $sync = $this->sessionVars['synchronous'] ?? null; - if ( in_array( $sync, [ 'EXTRA', 'FULL', 'NORMAL' ], true ) ) { - $this->query( "PRAGMA synchronous = $sync" ); + try { + $flags = self::QUERY_IGNORE_DBO_TRX | self::QUERY_NO_RETRY; + // Enforce LIKE to be case sensitive, just like MySQL + $this->query( 'PRAGMA case_sensitive_like = 1', __METHOD__, $flags ); + // Apply an optimizations or requirements regarding fsync() usage + $sync = $this->connectionVariables['synchronous'] ?? null; + if ( in_array( $sync, [ 'EXTRA', 'FULL', 'NORMAL', 'OFF' ], true ) ) { + $this->query( "PRAGMA synchronous = $sync", __METHOD__, $flags ); } - - return $this->conn; + } catch ( Exception $e ) { + // Connection was not fully initialized and is not safe for use + $this->conn = false; + throw $e; } - - return false; } /** - * @return string SQLite DB file path + * @return string|null SQLite DB file path + * @throws DBUnexpectedError * @since 1.25 */ public function getDbFilePath() { - return $this->dbPath; + return $this->dbPath ?? self::generateFileName( $this->dbDir, $this->getDBname() ); + } + + /** + * @return string|null Lock file directory + */ + public function getLockFileDirectory() { + if ( $this->dbPath !== null && !self::isProcessMemoryPath( $this->dbPath ) ) { + return dirname( $this->dbPath ) . '/locks'; + } elseif ( $this->dbDir !== null && !self::isProcessMemoryPath( $this->dbDir ) ) { + return $this->dbDir . '/locks'; + } + + return null; } /** @@ -248,13 +237,50 @@ class DatabaseSqlite extends Database { /** * Generates a database file name. Explicitly public for installer. * @param string $dir Directory where database resides - * @param string $dbName Database name + * @param string|bool $dbName Database name (or false from Database::factory, validated here) * @return string + * @throws DBUnexpectedError */ public static function generateFileName( $dir, $dbName ) { + if ( $dir == '' ) { + throw new DBUnexpectedError( null, __CLASS__ . ": no DB directory specified" ); + } elseif ( self::isProcessMemoryPath( $dir ) ) { + throw new DBUnexpectedError( + null, + __CLASS__ . ": cannot use process memory directory '$dir'" + ); + } elseif ( !strlen( $dbName ) ) { + throw new DBUnexpectedError( null, __CLASS__ . ": no DB name specified" ); + } + return "$dir/$dbName.sqlite"; } + /** + * @param string $path + * @return string + */ + private static function generateDatabaseName( $path ) { + if ( preg_match( '/^(:memory:$|file::memory:)/', $path ) ) { + // E.g. "file::memory:?cache=shared" => ":memory": + return ':memory:'; + } elseif ( preg_match( '/^file::([^?]+)\?mode=memory(&|$)/', $path, $m ) ) { + // E.g. "file:memdb1?mode=memory" => ":memdb1:" + return ":{$m[1]}:"; + } else { + // E.g. "/home/.../some_db.sqlite3" => "some_db" + return preg_replace( '/\.sqlite\d?$/', '', basename( $path ) ); + } + } + + /** + * @param string $path + * @return bool + */ + private static function isProcessMemoryPath( $path ) { + return preg_match( '/^(:memory:$|file:(:memory:|[^?]+\?mode=memory(&|$)))/', $path ); + } + /** * Check if the searchindext table is FTS enabled. * @return bool False if not enabled. @@ -263,7 +289,11 @@ class DatabaseSqlite extends Database { if ( self::$fulltextEnabled === null ) { self::$fulltextEnabled = false; $table = $this->tableName( 'searchindex' ); - $res = $this->query( "SELECT sql FROM sqlite_master WHERE tbl_name = '$table'", __METHOD__ ); + $res = $this->query( + "SELECT sql FROM sqlite_master WHERE tbl_name = '$table'", + __METHOD__, + self::QUERY_IGNORE_DBO_TRX + ); if ( $res ) { $row = $res->fetchRow(); self::$fulltextEnabled = stristr( $row['sql'], 'fts' ) !== false; @@ -305,13 +335,15 @@ class DatabaseSqlite extends Database { * @param string $fname Calling function name * @return IResultWrapper */ - function attachDatabase( $name, $file = false, $fname = __METHOD__ ) { - if ( !$file ) { - $file = self::generateFileName( $this->dbDir, $name ); - } - $file = $this->addQuotes( $file ); - - return $this->query( "ATTACH DATABASE $file AS $name", $fname ); + public function attachDatabase( $name, $file = false, $fname = __METHOD__ ) { + $file = is_string( $file ) ? $file : self::generateFileName( $this->dbDir, $name ); + $encFile = $this->addQuotes( $file ); + + return $this->query( + "ATTACH DATABASE $encFile AS $name", + $fname, + self::QUERY_IGNORE_DBO_TRX + ); } protected function isWriteQuery( $sql ) { @@ -338,9 +370,9 @@ class DatabaseSqlite extends Database { return false; } - $r = $res instanceof ResultWrapper ? $res->result : $res; - $this->lastAffectedRowCount = $r->rowCount(); - $res = new ResultWrapper( $this, $r->fetchAll() ); + $resource = ResultWrapper::unwrap( $res ); + $this->lastAffectedRowCount = $resource->rowCount(); + $res = new ResultWrapper( $this, $resource->fetchAll() ); return $res; } @@ -350,9 +382,7 @@ class DatabaseSqlite extends Database { */ function freeResult( $res ) { if ( $res instanceof ResultWrapper ) { - $res->result = null; - } else { - $res = null; + $res->free(); } } @@ -361,15 +391,11 @@ class DatabaseSqlite extends Database { * @return stdClass|bool */ function fetchObject( $res ) { - if ( $res instanceof ResultWrapper ) { - $r =& $res->result; - } else { - $r =& $res; - } + $resource =& ResultWrapper::unwrap( $res ); - $cur = current( $r ); + $cur = current( $resource ); if ( is_array( $cur ) ) { - next( $r ); + next( $resource ); $obj = new stdClass; foreach ( $cur as $k => $v ) { if ( !is_numeric( $k ) ) { @@ -388,14 +414,10 @@ class DatabaseSqlite extends Database { * @return array|bool */ function fetchRow( $res ) { - if ( $res instanceof ResultWrapper ) { - $r =& $res->result; - } else { - $r =& $res; - } - $cur = current( $r ); + $resource =& ResultWrapper::unwrap( $res ); + $cur = current( $resource ); if ( is_array( $cur ) ) { - next( $r ); + next( $resource ); return $cur; } @@ -411,9 +433,9 @@ class DatabaseSqlite extends Database { */ function numRows( $res ) { // false does not implement Countable - $r = $res instanceof ResultWrapper ? $res->result : $res; + $resource = ResultWrapper::unwrap( $res ); - return is_array( $r ) ? count( $r ) : 0; + return is_array( $resource ) ? count( $resource ) : 0; } /** @@ -421,10 +443,10 @@ class DatabaseSqlite extends Database { * @return int */ function numFields( $res ) { - $r = $res instanceof ResultWrapper ? $res->result : $res; - if ( is_array( $r ) && count( $r ) > 0 ) { + $resource = ResultWrapper::unwrap( $res ); + if ( is_array( $resource ) && count( $resource ) > 0 ) { // The size of the result array is twice the number of fields. (T67578) - return count( $r[0] ) / 2; + return count( $resource[0] ) / 2; } else { // If the result is empty return 0 return 0; @@ -437,9 +459,9 @@ class DatabaseSqlite extends Database { * @return bool */ function fieldName( $res, $n ) { - $r = $res instanceof ResultWrapper ? $res->result : $res; - if ( is_array( $r ) ) { - $keys = array_keys( $r[0] ); + $resource = ResultWrapper::unwrap( $res ); + if ( is_array( $resource ) ) { + $keys = array_keys( $resource[0] ); return $keys[$n]; } @@ -478,15 +500,11 @@ class DatabaseSqlite extends Database { * @param int $row */ function dataSeek( $res, $row ) { - if ( $res instanceof ResultWrapper ) { - $r =& $res->result; - } else { - $r =& $res; - } - reset( $r ); + $resource =& ResultWrapper::unwrap( $res ); + reset( $resource ); if ( $row > 0 ) { for ( $i = 0; $i < $row; $i++ ) { - next( $r ); + next( $resource ); } } } @@ -531,7 +549,10 @@ class DatabaseSqlite extends Database { $encTable = $this->addQuotes( $tableRaw ); $res = $this->query( - "SELECT 1 FROM sqlite_master WHERE type='table' AND name=$encTable" ); + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=$encTable", + __METHOD__, + self::QUERY_IGNORE_DBO_TRX + ); return $res->numRows() ? true : false; } @@ -548,7 +569,7 @@ class DatabaseSqlite extends Database { */ function indexInfo( $table, $index, $fname = __METHOD__ ) { $sql = 'PRAGMA index_info(' . $this->addQuotes( $this->indexName( $index ) ) . ')'; - $res = $this->query( $sql, $fname ); + $res = $this->query( $sql, $fname, self::QUERY_IGNORE_DBO_TRX ); if ( !$res || $res->numRows() == 0 ) { return false; } @@ -761,6 +782,12 @@ class DatabaseSqlite extends Database { return false; } + public function serverIsReadOnly() { + $path = $this->getDbFilePath(); + + return ( !self::isProcessMemoryPath( $path ) && !is_writable( $path ) ); + } + /** * @return string Wikitext of a link to the server software's web site */ @@ -788,7 +815,7 @@ class DatabaseSqlite extends Database { function fieldInfo( $table, $field ) { $tableName = $this->tableName( $table ); $sql = 'PRAGMA table_info(' . $this->addQuotes( $tableName ) . ')'; - $res = $this->query( $sql, __METHOD__ ); + $res = $this->query( $sql, __METHOD__, self::QUERY_IGNORE_DBO_TRX ); foreach ( $res as $row ) { if ( $row->name == $field ) { return new SQLiteField( $row, $tableName ); @@ -799,12 +826,11 @@ class DatabaseSqlite extends Database { } protected function doBegin( $fname = '' ) { - if ( $this->trxMode ) { + if ( $this->trxMode != '' ) { $this->query( "BEGIN {$this->trxMode}", $fname ); } else { $this->query( 'BEGIN', $fname ); } - $this->trxLevel = 1; } /** @@ -954,17 +980,19 @@ class DatabaseSqlite extends Database { } public function lock( $lockName, $method, $timeout = 5 ) { - if ( !is_dir( "{$this->dbDir}/locks" ) ) { // create dir as needed - if ( !is_writable( $this->dbDir ) || !mkdir( "{$this->dbDir}/locks" ) ) { - throw new DBError( $this, "Cannot create directory \"{$this->dbDir}/locks\"." ); - } + $status = $this->lockMgr->lock( [ $lockName ], LockManager::LOCK_EX, $timeout ); + if ( + $this->lockMgr instanceof FSLockManager && + $status->hasMessage( 'lockmanager-fail-openlock' ) + ) { + throw new DBError( $this, "Cannot create directory \"{$this->getLockFileDirectory()}\"" ); } - return $this->lockMgr->lock( [ $lockName ], LockManager::LOCK_EX, $timeout )->isOK(); + return $status->isOK(); } public function unlock( $lockName, $method ) { - return $this->lockMgr->unlock( [ $lockName ], LockManager::LOCK_EX )->isOK(); + return $this->lockMgr->unlock( [ $lockName ], LockManager::LOCK_EX )->isGood(); } /** @@ -1095,7 +1123,7 @@ class DatabaseSqlite extends Database { } $sql = "DROP TABLE " . $this->tableName( $tableName ); - return $this->query( $sql, $fName ); + return $this->query( $sql, $fName, self::QUERY_IGNORE_DBO_TRX ); } public function setTableAliases( array $aliases ) { @@ -1112,22 +1140,17 @@ class DatabaseSqlite extends Database { public function resetSequenceForTable( $table, $fname = __METHOD__ ) { $encTable = $this->addIdentifierQuotes( 'sqlite_sequence' ); $encName = $this->addQuotes( $this->tableName( $table, 'raw' ) ); - $this->query( "DELETE FROM $encTable WHERE name = $encName", $fname ); + $this->query( + "DELETE FROM $encTable WHERE name = $encName", + $fname, + self::QUERY_IGNORE_DBO_TRX + ); } public function databasesAreIndependent() { return true; } - /** - * @return string - */ - public function __toString() { - return is_object( $this->conn ) - ? 'SQLite ' . (string)$this->conn->getAttribute( PDO::ATTR_SERVER_VERSION ) - : '(not connected)'; - } - /** * @return PDO */ diff --git a/includes/libs/rdbms/database/IDatabase.php b/includes/libs/rdbms/database/IDatabase.php index 90e30fa08e..ef7f1e24f6 100644 --- a/includes/libs/rdbms/database/IDatabase.php +++ b/includes/libs/rdbms/database/IDatabase.php @@ -42,6 +42,8 @@ interface IDatabase { const TRIGGER_COMMIT = 2; /** @var int Callback triggered by ROLLBACK */ const TRIGGER_ROLLBACK = 3; + /** @var int Callback triggered by atomic section cancel (ROLLBACK TO SAVEPOINT) */ + const TRIGGER_CANCEL = 4; /** @var string Transaction is requested by regular caller outside of the DB layer */ const TRANSACTION_EXPLICIT = ''; @@ -106,6 +108,8 @@ interface IDatabase { /** @var int Enable compression in connection protocol */ const DBO_COMPRESS = 512; + /** @var int Idiom for "no special flags" */ + const QUERY_NORMAL = 0; /** @var int Ignore query errors and return false when they happen */ const QUERY_SILENCE_ERRORS = 1; // b/c for 1.32 query() argument; note that (int)true = 1 /** @@ -115,6 +119,10 @@ interface IDatabase { const QUERY_PSEUDO_PERMANENT = 2; /** @var int Enforce that a query does not make effective writes */ const QUERY_REPLICA_ROLE = 4; + /** @var int Ignore the current presence of any DBO_TRX flag */ + const QUERY_IGNORE_DBO_TRX = 8; + /** @var int Do not try to retry the query if the connection was lost */ + const QUERY_NO_RETRY = 16; /** @var bool Parameter to unionQueries() for UNION ALL */ const UNION_ALL = true; @@ -165,8 +173,10 @@ interface IDatabase { /** * Get the UNIX timestamp of the time that the transaction was established * - * This can be used to reason about the staleness of SELECT data - * in REPEATABLE-READ transaction isolation level. + * This can be used to reason about the staleness of SELECT data in REPEATABLE-READ + * transaction isolation level. Callers can assume that if a view-snapshot isolation + * is used, then the data read by SQL queries is *at least* up to date to that point + * (possibly more up-to-date since the first SELECT defines the snapshot). * * @return float|null Returns null if there is not active transaction * @since 1.25 @@ -213,14 +223,12 @@ interface IDatabase { public function getLBInfo( $name = null ); /** - * Set the LB info array, or a member of it. If called with one parameter, - * the LB info array is set to that parameter. If it is called with two - * parameters, the member with the given name is set to the given value. + * Set the entire array or a particular key of the managing load balancer info array * - * @param array|string $name - * @param array|null $value + * @param array|string $nameOrArray The new array or the name of a key to set + * @param array|null $value If $nameOrArray is a string, the new key value (null to unset) */ - public function setLBInfo( $name, $value = null ); + public function setLBInfo( $nameOrArray, $value = null ); /** * Set a lazy-connecting DB handle to the master DB (for replication status purposes) @@ -1148,7 +1156,7 @@ interface IDatabase { /** * Change the current database * - * This should not be called outside LoadBalancer for connections managed by a LoadBalancer + * This should only be called by a load balancer or if the handle is not attached to one * * @param string $db * @return bool True unless an exception was thrown @@ -1161,9 +1169,9 @@ interface IDatabase { /** * Set the current domain (database, schema, and table prefix) * - * This will throw an error for some database types if the database unspecified + * This will throw an error for some database types if the database is unspecified * - * This should not be called outside LoadBalancer for connections managed by a LoadBalancer + * This should only be called by a load balancer or if the handle is not attached to one * * @param string|DatabaseDomain $domain * @since 1.32 @@ -1216,9 +1224,12 @@ interface IDatabase { * $query .= $dbr->buildLike( $pattern ); * * @since 1.16 + * @param array[]|string|LikeMatch $param * @return string Fully built LIKE statement + * @phan-suppress-next-line PhanMismatchVariadicComment + * @phan-param array|string|LikeMatch ...$param T226223 */ - public function buildLike(); + public function buildLike( $param ); /** * Returns a token for buildLike() that denotes a '_' to be used in a LIKE query @@ -1569,6 +1580,9 @@ interface IDatabase { * * This is useful for combining cooperative locks and DB transactions. * + * Note this is called when the whole transaction is resolved. To take action immediately + * when an atomic section is cancelled, use onAtomicSectionCancel(). + * * @note do not assume that *other* IDatabase instances will be AUTOCOMMIT mode * * The callback takes the following arguments: @@ -1650,6 +1664,31 @@ interface IDatabase { */ public function onTransactionPreCommitOrIdle( callable $callback, $fname = __METHOD__ ); + /** + * Run a callback when the atomic section is cancelled. + * + * The callback is run just after the current atomic section, any outer + * atomic section, or the whole transaction is rolled back. + * + * An error is thrown if no atomic section is pending. The atomic section + * need not have been created with the ATOMIC_CANCELABLE flag. + * + * Queries in the function may be running in the context of an outer + * transaction or may be running in AUTOCOMMIT mode. The callback should + * use atomic sections if necessary. + * + * @note do not assume that *other* IDatabase instances will be AUTOCOMMIT mode + * + * The callback takes the following arguments: + * - IDatabase::TRIGGER_CANCEL or IDatabase::TRIGGER_ROLLBACK + * - This IDatabase instance + * + * @param callable $callback + * @param string $fname Caller name + * @since 1.34 + */ + public function onAtomicSectionCancel( callable $callback, $fname = __METHOD__ ); + /** * Run a callback after each time any transaction commits or rolls back * @@ -1903,7 +1942,7 @@ interface IDatabase { * * @throws DBError */ - public function commit( $fname = __METHOD__, $flush = '' ); + public function commit( $fname = __METHOD__, $flush = self::FLUSHING_ONE ); /** * Rollback a transaction previously started using begin(). @@ -1925,7 +1964,7 @@ interface IDatabase { * @throws DBError * @since 1.23 Added $flush parameter */ - public function rollback( $fname = __METHOD__, $flush = '' ); + public function rollback( $fname = __METHOD__, $flush = self::FLUSHING_ONE ); /** * Commit any transaction but error out if writes or callbacks are pending @@ -1936,10 +1975,20 @@ interface IDatabase { * useful to call on a replica DB after waiting on replication to catch up to the master. * * @param string $fname Calling function name + * @param string $flush Flush flag, set to situationally valid IDatabase::FLUSHING_* + * constant to disable warnings about explicitly committing implicit transactions, + * or calling commit when no transaction is in progress. + * + * This will trigger an exception if there is an ongoing explicit transaction. + * + * Only set the flush flag if you are sure that these warnings are not applicable, + * and no explicit transactions are open. + * * @throws DBError * @since 1.28 + * @since 1.34 Added $flush parameter */ - public function flushSnapshot( $fname = __METHOD__ ); + public function flushSnapshot( $fname = __METHOD__, $flush = self::FLUSHING_ONE ); /** * Convert a timestamp in one of the formats accepted by wfTimestamp() @@ -2071,7 +2120,7 @@ interface IDatabase { * * @param string $lockName Name of lock to aquire * @param string $method Name of the calling method - * @param int $timeout Acquisition timeout in seconds + * @param int $timeout Acquisition timeout in seconds (0 means non-blocking) * @return bool * @throws DBError */ @@ -2195,6 +2244,15 @@ interface IDatabase { * @since 1.31 */ public function setIndexAliases( array $aliases ); + + /** + * Get a debugging string that mentions the database type, the ID of this instance, + * and the ID of any underlying connection resource or driver object if one is present + * + * @return string " object #" or " object # (resource/handle id #)" + * @since 1.34 + */ + public function __toString(); } /** diff --git a/includes/libs/rdbms/database/domain/DatabaseDomain.php b/includes/libs/rdbms/database/domain/DatabaseDomain.php index 5dd4b49fba..5698cf824d 100644 --- a/includes/libs/rdbms/database/domain/DatabaseDomain.php +++ b/includes/libs/rdbms/database/domain/DatabaseDomain.php @@ -53,9 +53,7 @@ class DatabaseDomain { } $this->schema = $schema; if ( !is_string( $prefix ) ) { - throw new InvalidArgumentException( 'Prefix must be a string.' ); - } elseif ( $prefix !== '' && substr( $prefix, -1, 1 ) !== '_' ) { - throw new InvalidArgumentException( 'A non-empty prefix must end with "_".' ); + throw new InvalidArgumentException( "Prefix must be a string." ); } $this->prefix = $prefix; } @@ -92,7 +90,10 @@ class DatabaseDomain { $schema = null; } - return new self( $database, $schema, $prefix ); + $instance = new self( $database, $schema, $prefix ); + $instance->equivalentString = (string)$domain; + + return $instance; } /** diff --git a/includes/libs/rdbms/database/resultwrapper/FakeResultWrapper.php b/includes/libs/rdbms/database/resultwrapper/FakeResultWrapper.php index 3709de7de2..1094fafc96 100644 --- a/includes/libs/rdbms/database/resultwrapper/FakeResultWrapper.php +++ b/includes/libs/rdbms/database/resultwrapper/FakeResultWrapper.php @@ -8,21 +8,29 @@ use stdClass; * Overloads the relevant methods of the real ResultsWrapper so it * doesn't go anywhere near an actual database. */ -class FakeResultWrapper extends ResultWrapper { - /** @var stdClass[]|array[] $result */ +class FakeResultWrapper implements IResultWrapper { + /** @var stdClass[]|array[] */ + protected $result; + + /** @var int */ + protected $pos = 0; /** - * @param stdClass[]|array[] $rows + * @param stdClass[]|array[]|FakeResultWrapper $result */ - function __construct( array $rows ) { - parent::__construct( null, $rows ); + public function __construct( $result ) { + if ( $result instanceof self ) { + $this->result = $result->result; + } else { + $this->result = $result; + } } - function numRows() { + public function numRows() { return count( $this->result ); } - function fetchObject() { + public function fetchObject() { $current = $this->current(); $this->next(); @@ -30,43 +38,43 @@ class FakeResultWrapper extends ResultWrapper { return $current; } - function fetchRow() { + public function fetchRow() { $row = $this->valid() ? $this->result[$this->pos] : false; $this->next(); - return is_object( $row ) ? (array)$row : $row; + return is_object( $row ) ? get_object_vars( $row ) : $row; } - function seek( $pos ) { + public function seek( $pos ) { $this->pos = $pos; } - function free() { + public function free() { $this->result = null; } - function rewind() { + public function rewind() { $this->pos = 0; } - function current() { + public function current() { $row = $this->valid() ? $this->result[$this->pos] : false; return is_array( $row ) ? (object)$row : $row; } - function key() { + public function key() { return $this->pos; } - function next() { + public function next() { $this->pos++; return $this->current(); } - function valid() { + public function valid() { return array_key_exists( $this->pos, $this->result ); } } diff --git a/includes/libs/rdbms/database/resultwrapper/ResultWrapper.php b/includes/libs/rdbms/database/resultwrapper/ResultWrapper.php index b7938ad256..3e509677a3 100644 --- a/includes/libs/rdbms/database/resultwrapper/ResultWrapper.php +++ b/includes/libs/rdbms/database/resultwrapper/ResultWrapper.php @@ -4,29 +4,27 @@ namespace Wikimedia\Rdbms; use stdClass; use RuntimeException; +use InvalidArgumentException; /** * Result wrapper for grabbing data queried from an IDatabase object * + * Only IDatabase-related classes should construct these. Other code may + * use the FakeResultWrapper class for convenience or compatibility shims. + * * Note that using the Iterator methods in combination with the non-Iterator - * DB result iteration functions may cause rows to be skipped or repeated. + * IDatabase result iteration functions may cause rows to be skipped or repeated. * * By default, this will use the iteration methods of the IDatabase handle if provided. * Subclasses can override methods to make it solely work on the result resource instead. - * If no database is provided, and the subclass does not override the DB iteration methods, - * then a RuntimeException will be thrown when iteration is attempted. - * - * The result resource field should not be accessed from non-Database related classes. - * It is database class specific and is stored here to associate iterators with queries. * * @ingroup Database */ class ResultWrapper implements IResultWrapper { - /** @var resource|array|null Optional underlying result handle for subclass usage */ - public $result; - - /** @var IDatabase|null */ + /** @var IDatabase */ protected $db; + /** @var mixed|null RDBMS driver-specific result resource */ + protected $result; /** @var int */ protected $pos = 0; @@ -34,20 +32,39 @@ class ResultWrapper implements IResultWrapper { protected $currentRow; /** - * Create a row iterator from a result resource and an optional Database object - * - * Only Database-related classes should construct ResultWrapper. Other code may - * use the FakeResultWrapper subclass for convenience or compatibility shims, however. - * - * @param IDatabase|null $db Optional database handle - * @param ResultWrapper|array|resource $result Optional underlying result handle + * @param IDatabase $db Database handle that the result comes from + * @param self|mixed $result RDBMS driver-specific result resource */ - public function __construct( IDatabase $db = null, $result ) { + public function __construct( IDatabase $db, $result ) { $this->db = $db; - if ( $result instanceof ResultWrapper ) { + if ( $result instanceof self ) { $this->result = $result->result; - } else { + } elseif ( $result !== null ) { $this->result = $result; + } else { + throw new InvalidArgumentException( "Null result resource provided" ); + } + } + + /** + * Get the underlying RDBMS driver-specific result resource + * + * The result resource field should not be accessed from non-Database related classes. + * It is database class specific and is stored here to associate iterators with queries. + * + * @param self|mixed &$res + * @return mixed + * @since 1.34 + */ + public static function &unwrap( &$res ) { + if ( $res instanceof self ) { + if ( $res->result === null ) { + throw new RuntimeException( "The result resource was already freed" ); + } + + return $res->result; + } else { + return $res; } } @@ -110,7 +127,7 @@ class ResultWrapper implements IResultWrapper { */ private function getDB() { if ( !$this->db ) { - throw new RuntimeException( static::class . ' needs a DB handle for iteration.' ); + throw new RuntimeException( "Database handle was already freed" ); } return $this->db; diff --git a/includes/libs/rdbms/lbfactory/ILBFactory.php b/includes/libs/rdbms/lbfactory/ILBFactory.php index c5dbfc58a8..812064a772 100644 --- a/includes/libs/rdbms/lbfactory/ILBFactory.php +++ b/includes/libs/rdbms/lbfactory/ILBFactory.php @@ -140,7 +140,7 @@ interface ILBFactory { /** * Get cached (tracked) load balancers for all main database clusters * - * @return LoadBalancer[] Map of (cluster name => LoadBalancer) + * @return ILoadBalancer[] Map of (cluster name => ILoadBalancer) * @since 1.29 */ public function getAllMainLBs(); @@ -148,7 +148,7 @@ interface ILBFactory { /** * Get cached (tracked) load balancers for all external database clusters * - * @return LoadBalancer[] Map of (cluster name => LoadBalancer) + * @return ILoadBalancer[] Map of (cluster name => ILoadBalancer) * @since 1.29 */ public function getAllExternalLBs(); @@ -180,6 +180,8 @@ interface ILBFactory { /** * Commit all replica DB transactions so as to flush any REPEATABLE-READ or SSI snapshot * + * This is useful for getting rid of stale data from an implicit transaction round + * * @param string $fname Caller name */ public function flushReplicaSnapshots( $fname = __METHOD__ ); diff --git a/includes/libs/rdbms/lbfactory/LBFactory.php b/includes/libs/rdbms/lbfactory/LBFactory.php index 8608a7d6f9..a85e1e544f 100644 --- a/includes/libs/rdbms/lbfactory/LBFactory.php +++ b/includes/libs/rdbms/lbfactory/LBFactory.php @@ -85,7 +85,9 @@ abstract class LBFactory implements ILBFactory { /** @var callable[] */ private $replicationWaitCallbacks = []; - /** @var mixed */ + /** var int An identifier for this class instance */ + private $id; + /** @var int|null Ticket used to delegate transaction ownership */ private $ticket; /** @var string|bool String if a requested DBO_TRX transaction round is active */ private $trxRoundId = false; @@ -153,6 +155,7 @@ abstract class LBFactory implements ILBFactory { $this->defaultGroup = $conf['defaultGroup'] ?? null; $this->secret = $conf['secret'] ?? ''; + $this->id = mt_rand(); $this->ticket = mt_rand(); } @@ -231,6 +234,12 @@ abstract class LBFactory implements ILBFactory { } public function flushReplicaSnapshots( $fname = __METHOD__ ) { + if ( $this->trxRoundId !== false && $this->trxRoundId !== $fname ) { + $this->queryLogger->warning( + "$fname: transaction round '{$this->trxRoundId}' still running", + [ 'trace' => ( new RuntimeException() )->getTraceAsString() ] + ); + } $this->forEachLBCallMethod( 'flushReplicaSnapshots', [ $fname ] ); } @@ -246,12 +255,12 @@ abstract class LBFactory implements ILBFactory { if ( $this->trxRoundId !== false ) { throw new DBTransactionError( null, - "$fname: transaction round '{$this->trxRoundId}' already started." + "$fname: transaction round '{$this->trxRoundId}' already started" ); } $this->trxRoundId = $fname; // Set DBO_TRX flags on all appropriate DBs - $this->forEachLBCallMethod( 'beginMasterChanges', [ $fname ] ); + $this->forEachLBCallMethod( 'beginMasterChanges', [ $fname, $this->id ] ); $this->trxRoundStage = self::ROUND_CURSORY; } @@ -261,7 +270,7 @@ abstract class LBFactory implements ILBFactory { if ( $this->trxRoundId !== false && $this->trxRoundId !== $fname ) { throw new DBTransactionError( null, - "$fname: transaction round '{$this->trxRoundId}' still running." + "$fname: transaction round '{$this->trxRoundId}' still running" ); } /** @noinspection PhpUnusedLocalVariableInspection */ @@ -269,17 +278,17 @@ abstract class LBFactory implements ILBFactory { // Run pre-commit callbacks and suppress post-commit callbacks, aborting on failure do { $count = 0; // number of callbacks executed this iteration - $this->forEachLB( function ( ILoadBalancer $lb ) use ( &$count ) { - $count += $lb->finalizeMasterChanges(); + $this->forEachLB( function ( ILoadBalancer $lb ) use ( &$count, $fname ) { + $count += $lb->finalizeMasterChanges( $fname, $this->id ); } ); } while ( $count > 0 ); $this->trxRoundId = false; // Perform pre-commit checks, aborting on failure - $this->forEachLBCallMethod( 'approveMasterChanges', [ $options ] ); + $this->forEachLBCallMethod( 'approveMasterChanges', [ $options, $fname, $this->id ] ); // Log the DBs and methods involved in multi-DB transactions $this->logIfMultiDbTransaction(); // Actually perform the commit on all master DB connections and revert DBO_TRX - $this->forEachLBCallMethod( 'commitMasterChanges', [ $fname ] ); + $this->forEachLBCallMethod( 'commitMasterChanges', [ $fname, $this->id ] ); // Run all post-commit callbacks in a separate step $this->trxRoundStage = self::ROUND_COMMIT_CALLBACKS; $e = $this->executePostTransactionCallbacks(); @@ -294,7 +303,7 @@ abstract class LBFactory implements ILBFactory { $this->trxRoundStage = self::ROUND_ROLLING_BACK; $this->trxRoundId = false; // Actually perform the rollback on all master DB connections and revert DBO_TRX - $this->forEachLBCallMethod( 'rollbackMasterChanges', [ $fname ] ); + $this->forEachLBCallMethod( 'rollbackMasterChanges', [ $fname, $this->id ] ); // Run all post-commit callbacks in a separate step $this->trxRoundStage = self::ROUND_ROLLBACK_CALLBACKS; $this->executePostTransactionCallbacks(); @@ -305,17 +314,18 @@ abstract class LBFactory implements ILBFactory { * @return Exception|null */ private function executePostTransactionCallbacks() { + $fname = __METHOD__; // Run all post-commit callbacks until new ones stop getting added $e = null; // first callback exception do { - $this->forEachLB( function ( ILoadBalancer $lb ) use ( &$e ) { - $ex = $lb->runMasterTransactionIdleCallbacks(); + $this->forEachLB( function ( ILoadBalancer $lb ) use ( &$e, $fname ) { + $ex = $lb->runMasterTransactionIdleCallbacks( $fname, $this->id ); $e = $e ?: $ex; } ); } while ( $this->hasMasterChanges() ); // Run all listener callbacks once - $this->forEachLB( function ( ILoadBalancer $lb ) use ( &$e ) { - $ex = $lb->runMasterTransactionListenerCallbacks(); + $this->forEachLB( function ( ILoadBalancer $lb ) use ( &$e, $fname ) { + $ex = $lb->runMasterTransactionListenerCallbacks( $fname, $this->id ); $e = $e ?: $ex; } ); @@ -412,15 +422,20 @@ abstract class LBFactory implements ILBFactory { // time needed to wait on the next clusters. $masterPositions = array_fill( 0, count( $lbs ), false ); foreach ( $lbs as $i => $lb ) { - if ( $lb->getServerCount() <= 1 ) { - // T29975 - Don't try to wait for replica DBs if there are none - // Prevents permission error when getting master position - continue; - } elseif ( $opts['ifWritesSince'] - && $lb->lastMasterChangeTimestamp() < $opts['ifWritesSince'] + if ( + // No writes to wait on getting replicated + !$lb->hasMasterConnection() || + // No replication; avoid getMasterPos() permissions errors (T29975) + !$lb->hasStreamingReplicaServers() || + // No writes since the last replication wait + ( + $opts['ifWritesSince'] && + $lb->lastMasterChangeTimestamp() < $opts['ifWritesSince'] + ) ) { - continue; // no writes since the last wait + continue; // no need to wait } + $masterPositions[$i] = $lb->getMasterPos(); } @@ -453,8 +468,10 @@ abstract class LBFactory implements ILBFactory { public function getEmptyTransactionTicket( $fname ) { if ( $this->hasMasterChanges() ) { - $this->queryLogger->error( __METHOD__ . ": $fname does not have outer scope.\n" . - ( new RuntimeException() )->getTraceAsString() ); + $this->queryLogger->error( + __METHOD__ . ": $fname does not have outer scope", + [ 'trace' => ( new RuntimeException() )->getTraceAsString() ] + ); return null; } @@ -464,8 +481,10 @@ abstract class LBFactory implements ILBFactory { final public function commitAndWaitForReplication( $fname, $ticket, array $opts = [] ) { if ( $ticket !== $this->ticket ) { - $this->perfLogger->error( __METHOD__ . ": $fname does not have outer scope.\n" . - ( new RuntimeException() )->getTraceAsString() ); + $this->perfLogger->error( + __METHOD__ . ": $fname does not have outer scope", + [ 'trace' => ( new RuntimeException() )->getTraceAsString() ] + ); return false; } @@ -473,7 +492,7 @@ abstract class LBFactory implements ILBFactory { // The transaction owner and any caller with the empty transaction ticket can commit // so that getEmptyTransactionTicket() callers don't risk seeing DBTransactionError. if ( $this->trxRoundId !== false && $fname !== $this->trxRoundId ) { - $this->queryLogger->info( "$fname: committing on behalf of {$this->trxRoundId}." ); + $this->queryLogger->info( "$fname: committing on behalf of {$this->trxRoundId}" ); $fnameEffective = $this->trxRoundId; } else { $fnameEffective = $fname; @@ -527,11 +546,13 @@ abstract class LBFactory implements ILBFactory { } elseif ( $this->memStash instanceof EmptyBagOStuff ) { // No where to store any DB positions and wait for them to appear $this->chronProt->setEnabled( false ); - $this->replLogger->info( 'Cannot use ChronologyProtector with EmptyBagOStuff.' ); + $this->replLogger->info( 'Cannot use ChronologyProtector with EmptyBagOStuff' ); } - $this->replLogger->debug( __METHOD__ . ': using request info ' . - json_encode( $this->requestInfo, JSON_PRETTY_PRINT ) ); + $this->replLogger->debug( + __METHOD__ . ': request info ' . + json_encode( $this->requestInfo, JSON_PRETTY_PRINT ) + ); return $this->chronProt; } @@ -605,7 +626,8 @@ abstract class LBFactory implements ILBFactory { // being called later (but before the first connection attempt) (T192611) $this->getChronologyProtector()->applySessionReplicationPosition( $lb ); }, - 'roundStage' => $initStage + 'roundStage' => $initStage, + 'ownerId' => $this->id ]; } @@ -614,7 +636,7 @@ abstract class LBFactory implements ILBFactory { */ protected function initLoadBalancer( ILoadBalancer $lb ) { if ( $this->trxRoundId !== false ) { - $lb->beginMasterChanges( $this->trxRoundId ); // set DBO_TRX + $lb->beginMasterChanges( $this->trxRoundId, $this->id ); // set DBO_TRX } $lb->setTableAliases( $this->tableAliases ); @@ -670,7 +692,7 @@ abstract class LBFactory implements ILBFactory { public function appendShutdownCPIndexAsQuery( $url, $index ) { $usedCluster = 0; $this->forEachLB( function ( ILoadBalancer $lb ) use ( &$usedCluster ) { - $usedCluster |= ( $lb->getServerCount() > 1 ); + $usedCluster |= $lb->hasStreamingReplicaServers(); } ); if ( !$usedCluster ) { @@ -722,7 +744,7 @@ abstract class LBFactory implements ILBFactory { public function setRequestInfo( array $info ) { if ( $this->chronProt ) { - throw new LogicException( 'ChronologyProtector already initialized.' ); + throw new LogicException( 'ChronologyProtector already initialized' ); } $this->requestInfo = $info + $this->requestInfo; diff --git a/includes/libs/rdbms/lbfactory/LBFactoryMulti.php b/includes/libs/rdbms/lbfactory/LBFactoryMulti.php index aec99f4ec7..f675b58778 100644 --- a/includes/libs/rdbms/lbfactory/LBFactoryMulti.php +++ b/includes/libs/rdbms/lbfactory/LBFactoryMulti.php @@ -34,55 +34,42 @@ use InvalidArgumentException; class LBFactoryMulti extends LBFactory { /** @var array A map of database names to section names */ private $sectionsByDB; - /** * @var array A 2-d map. For each section, gives a map of server names to * load ratios */ private $sectionLoads; - /** * @var array[] Server info associative array * @note The host, hostName and load entries will be overridden */ private $serverTemplate; - // Optional settings - /** @var array A 3-d map giving server load ratios for each section and group */ private $groupLoadsBySection = []; - /** @var array A 3-d map giving server load ratios by DB name */ private $groupLoadsByDB = []; - /** @var array A map of hostname to IP address */ private $hostsByName = []; - /** @var array A map of external storage cluster name to server load map */ private $externalLoads = []; - /** * @var array A set of server info keys overriding serverTemplate for * external storage */ private $externalTemplateOverrides; - /** * @var array A 2-d map overriding serverTemplate and * externalTemplateOverrides on a server-by-server basis. Applies to both * core and external storage */ private $templateOverridesByServer; - /** @var array A 2-d map overriding the server info by section */ private $templateOverridesBySection; - /** @var array A 2-d map overriding the server info by external storage cluster */ private $templateOverridesByCluster; - /** @var array An override array for all master servers */ private $masterTemplateOverrides; - /** * @var array|bool A map of section name to read-only message. Missing or * false for read/write @@ -91,16 +78,12 @@ class LBFactoryMulti extends LBFactory { /** @var LoadBalancer[] */ private $mainLBs = []; - /** @var LoadBalancer[] */ private $extLBs = []; - /** @var string */ private $loadMonitorClass = 'LoadMonitor'; - /** @var string */ private $lastDomain; - /** @var string */ private $lastSection; @@ -191,22 +174,19 @@ class LBFactoryMulti extends LBFactory { if ( $this->lastDomain === $domain ) { return $this->lastSection; } - list( $dbName, ) = $this->getDBNameAndPrefix( $domain ); - $section = $this->sectionsByDB[$dbName] ?? 'DEFAULT'; + + $database = $this->getDatabaseFromDomain( $domain ); + $section = $this->sectionsByDB[$database] ?? 'DEFAULT'; $this->lastSection = $section; $this->lastDomain = $domain; return $section; } - /** - * @param bool|string $domain - * @return LoadBalancer - */ public function newMainLB( $domain = false ) { - list( $dbName, ) = $this->getDBNameAndPrefix( $domain ); + $database = $this->getDatabaseFromDomain( $domain ); $section = $this->getSectionForDomain( $domain ); - $groupLoads = $this->groupLoadsByDB[$dbName] ?? []; + $groupLoads = $this->groupLoadsByDB[$database] ?? []; if ( isset( $this->groupLoadsBySection[$section] ) ) { $groupLoads = array_merge_recursive( @@ -232,10 +212,6 @@ class LBFactoryMulti extends LBFactory { ); } - /** - * @param DatabaseDomain|string|bool $domain Domain ID, or false for the current domain - * @return LoadBalancer - */ public function getMainLB( $domain = false ) { $section = $this->getSectionForDomain( $domain ); if ( !isset( $this->mainLBs[$section] ) ) { @@ -379,23 +355,14 @@ class LBFactoryMulti extends LBFactory { /** * @param DatabaseDomain|string|bool $domain Domain ID, or false for the current domain - * @return array [database name, table prefix] + * @return string */ - private function getDBNameAndPrefix( $domain = false ) { - $domain = ( $domain === false ) - ? $this->localDomain - : DatabaseDomain::newFromId( $domain ); - - return [ $domain->getDatabase(), $domain->getTablePrefix() ]; + private function getDatabaseFromDomain( $domain = false ) { + return ( $domain === false ) + ? $this->localDomain->getDatabase() + : DatabaseDomain::newFromId( $domain )->getDatabase(); } - /** - * Execute a function for each tracked load balancer - * The callback is called with the load balancer as the first parameter, - * and $params passed as the subsequent parameters. - * @param callable $callback - * @param array $params - */ public function forEachLB( $callback, array $params = [] ) { foreach ( $this->mainLBs as $lb ) { $callback( $lb, ...$params ); diff --git a/includes/libs/rdbms/lbfactory/LBFactorySimple.php b/includes/libs/rdbms/lbfactory/LBFactorySimple.php index 49054e083d..fd76d88dd0 100644 --- a/includes/libs/rdbms/lbfactory/LBFactorySimple.php +++ b/includes/libs/rdbms/lbfactory/LBFactorySimple.php @@ -70,20 +70,12 @@ class LBFactorySimple extends LBFactory { $this->loadMonitorClass = $conf['loadMonitorClass'] ?? 'LoadMonitor'; } - /** - * @param bool|string $domain - * @return LoadBalancer - */ public function newMainLB( $domain = false ) { return $this->newLoadBalancer( $this->servers ); } - /** - * @param bool|string $domain - * @return LoadBalancer - */ public function getMainLB( $domain = false ) { - if ( !isset( $this->mainLB ) ) { + if ( !$this->mainLB ) { $this->mainLB = $this->newMainLB( $domain ); } @@ -132,14 +124,6 @@ class LBFactorySimple extends LBFactory { return $lb; } - /** - * Execute a function for each tracked load balancer - * The callback is called with the load balancer as the first parameter, - * and $params passed as the subsequent parameters. - * - * @param callable $callback - * @param array $params - */ public function forEachLB( $callback, array $params = [] ) { if ( isset( $this->mainLB ) ) { $callback( $this->mainLB, ...$params ); diff --git a/includes/libs/rdbms/loadbalancer/ILoadBalancer.php b/includes/libs/rdbms/loadbalancer/ILoadBalancer.php index faa9654372..990705c45f 100644 --- a/includes/libs/rdbms/loadbalancer/ILoadBalancer.php +++ b/includes/libs/rdbms/loadbalancer/ILoadBalancer.php @@ -23,6 +23,7 @@ namespace Wikimedia\Rdbms; use Exception; +use LogicException; use InvalidArgumentException; /** @@ -62,6 +63,8 @@ use InvalidArgumentException; * Note that lag is still possible depending on how wsrep-sync-wait is set server-side. * - Read-only archive clones: set 'is static' in the server configuration maps. This will * treat all such DBs as having 0 lag. + * - Externally updated dataset clones: set 'is static' in the server configuration maps. + * This will treat all such DBs as having 0 lag. * - SQL load balancing proxy: any proxy should handle lag checks on its own, so the 'max lag' * parameter should probably be set to INF in the server configuration maps. This will make * the load balancer ignore whatever it detects as the lag of the logical replica is (which @@ -83,9 +86,15 @@ interface ILoadBalancer { /** @var string Domain specifier when no specific database needs to be selected */ const DOMAIN_ANY = ''; + /** @var string The generic query group */ + const GROUP_GENERIC = ''; /** @var int DB handle should have DBO_TRX disabled and the caller will leave it as such */ const CONN_TRX_AUTOCOMMIT = 1; + /** @var int Return null on connection failure instead of throwing an exception */ + const CONN_SILENCE_ERRORS = 2; + /** @var int Caller is requesting the master DB server for possibly writes */ + const CONN_INTENT_WRITABLE = 4; /** @var string Manager of ILoadBalancer instances is running post-commit callbacks */ const STAGE_POSTCOMMIT_CALLBACKS = 'stage-postcommit-callbacks'; @@ -96,26 +105,28 @@ interface ILoadBalancer { * Construct a manager of IDatabase connection objects * * @param array $params Parameter map with keys: - * - servers : Required. Array of server info structures. - * - localDomain: A DatabaseDomain or domain ID string. - * - loadMonitor : Name of a class used to fetch server lag and load. + * - servers : List of server info structures + * - localDomain: A DatabaseDomain or domain ID string + * - loadMonitor : Name of a class used to fetch server lag and load * - readOnlyReason : Reason the master DB is read-only if so [optional] * - waitTimeout : Maximum time to wait for replicas for consistency [optional] * - maxLag: Try to avoid DB replicas with lag above this many seconds [optional] * - srvCache : BagOStuff object for server cache [optional] * - wanCache : WANObjectCache object [optional] * - chronologyCallback: Callback to run before the first connection attempt [optional] + * - defaultGroup: Default query group; the generic group if not specified [optional] * - hostname : The name of the current server [optional] - * - cliMode: Whether the execution context is a CLI script. [optional] - * - profiler : Class name or instance with profileIn()/profileOut() methods. [optional] - * - trxProfiler: TransactionProfiler instance. [optional] - * - replLogger: PSR-3 logger instance. [optional] - * - connLogger: PSR-3 logger instance. [optional] - * - queryLogger: PSR-3 logger instance. [optional] - * - perfLogger: PSR-3 logger instance. [optional] - * - errorLogger : Callback that takes an Exception and logs it. [optional] - * - deprecationLogger: Callback to log a deprecation warning. [optional] + * - cliMode: Whether the execution context is a CLI script [optional] + * - profiler : Class name or instance with profileIn()/profileOut() methods [optional] + * - trxProfiler: TransactionProfiler instance [optional] + * - replLogger: PSR-3 logger instance [optional] + * - connLogger: PSR-3 logger instance [optional] + * - queryLogger: PSR-3 logger instance [optional] + * - perfLogger: PSR-3 logger instance [optional] + * - errorLogger : Callback that takes an Exception and logs it [optional] + * - deprecationLogger: Callback to log a deprecation warning [optional] * - roundStage: STAGE_POSTCOMMIT_* class constant; for internal use [optional] + * - ownerId: integer ID of an LBFactory instance that manages this instance [optional] * @throws InvalidArgumentException */ public function __construct( array $params ); @@ -145,15 +156,18 @@ interface ILoadBalancer { public function redefineLocalDomain( $domain ); /** - * Get the index of the reader connection, which may be a replica DB + * Get the server index of the reader connection for a given group * - * This takes into account load ratios and lag times. It should - * always return a consistent index during a given invocation. + * This takes into account load ratios and lag times. It should return a consistent + * index during the life time of the load balancer. This initially checks replica DBs + * for connectivity to avoid returning an unusable server. This means that connections + * might be attempted by calling this method (usally one at the most but possibly more). + * Subsequent calls with the same $group will not need to make new connection attempts + * since the acquired connection for each group is preserved. * - * Side effect: opens connections to databases - * @param string|bool $group Query group, or false for the generic group - * @param string|bool $domain Domain ID, or false for the current domain - * @throws DBError + * @param string|bool $group Query group or false for the generic group + * @param string|bool $domain DB domain ID or false for the local domain + * @throws DBError If no live handle can be obtained * @return bool|int|string */ public function getReaderIndex( $group = false, $domain = false ); @@ -196,7 +210,8 @@ interface ILoadBalancer { * Get any open connection to a given server index, local or foreign * * Use CONN_TRX_AUTOCOMMIT to only look for connections opened with that flag. - * Avoid the use of begin() or startAtomic() on any such connections. + * Avoid the use of transaction methods like IDatabase::begin() or IDatabase::startAtomic() + * on any such connections. * * @param int $i Server index or DB_MASTER/DB_REPLICA * @param int $flags Bitfield of CONN_* class constants @@ -205,152 +220,207 @@ interface ILoadBalancer { public function getAnyOpenConnection( $i, $flags = 0 ); /** - * Get a connection handle by server index - * - * The CONN_TRX_AUTOCOMMIT flag is ignored for databases with ATTR_DB_LEVEL_LOCKING - * (e.g. sqlite) in order to avoid deadlocks. ILoadBalancer::getServerAttributes() - * can be used to check such flags beforehand. - * - * If the caller uses $domain or sets CONN_TRX_AUTOCOMMIT in $flags, then it must - * also call ILoadBalancer::reuseConnection() on the handle when finished using it. - * In all other cases, this is not necessary, though not harmful either. - * Avoid the use of begin() or startAtomic() on any such connections. + * Get a live handle for a real or virtual (DB_MASTER/DB_REPLICA) server index + * + * The server index, $i, can be one of the following: + * - DB_REPLICA: a server index will be selected by the load balancer based on read + * weight, connectivity, and replication lag. Note that the master server might be + * configured with read weight. If $groups is empty then it means "the generic group", + * in which case all servers defined with read weight will be considered. Additional + * query groups can be configured, having their own list of server indexes and read + * weights. If a query group list is provided in $groups, then each recognized group + * will be tried, left-to-right, until server index selection succeeds or all groups + * have been tried, in which case the generic group will be tried. + * - DB_MASTER: the master server index will be used; the same as getWriterIndex(). + * The value of $groups should be [] when using this server index. + * - Specific server index: a positive integer can be provided to use the server with + * that index. An error will be thrown in no such server index is recognized. This + * server selection method is usually only useful for internal load balancing logic. + * The value of $groups should be [] when using a specific server index. + * + * Handles acquired by this method, getConnectionRef(), getLazyConnectionRef(), and + * getMaintenanceConnectionRef() use the same set of shared connection pools. Callers that + * get a *local* DB domain handle for the same server will share one handle for all of those + * callers using CONN_TRX_AUTOCOMMIT (via $flags) and one handle for all of those callers not + * using CONN_TRX_AUTOCOMMIT. Callers that get a *foreign* DB domain handle (via $domain) will + * share any handle that has the right CONN_TRX_AUTOCOMMIT mode and is already on the right + * DB domain. Otherwise, one of the "free for reuse" handles will be claimed or a new handle + * will be made if there are none. + * + * Handle sharing is particularly useful when callers get local DB domain (the default), + * transaction round aware (the default), DB_MASTER handles. All such callers will operate + * within a single database transaction as a consequence. Handle sharing is also useful when + * callers get local DB domain (the default), transaction round aware (the default), samely + * query grouped (the default), DB_REPLICA handles. All such callers will operate within a + * single database transaction as a consequence. + * + * Calling functions that use $domain must call reuseConnection() once the last query of the + * function is executed. This lets the load balancer share this handle with other callers + * requesting connections on different database domains. + * + * Use CONN_TRX_AUTOCOMMIT to use a separate pool of only auto-commit handles. This flag + * is ignored for databases with ATTR_DB_LEVEL_LOCKING (e.g. sqlite) in order to avoid + * deadlocks. getServerAttributes() can be used to check such attributes beforehand. Avoid + * using IDatabase::begin() and IDatabase::commit() on such handles. If it is not possible + * to avoid using methods like IDatabase::startAtomic() and IDatabase::endAtomic(), callers + * should at least make sure that the atomic sections are closed on failure via try/catch + * and IDatabase::cancelAtomic(). + * + * @see ILoadBalancer::reuseConnection() + * @see ILoadBalancer::getServerAttributes() * * @param int $i Server index (overrides $groups) or DB_MASTER/DB_REPLICA - * @param array|string|bool $groups Query group(s), or false for the generic reader - * @param string|bool $domain Domain ID, or false for the current domain + * @param string[]|string $groups Query group(s) in preference order; [] for the default group + * @param string|bool $domain DB domain ID or false for the local domain * @param int $flags Bitfield of CONN_* class constants * * @note This method throws DBAccessError if ILoadBalancer::disable() was called * - * @throws DBError - * @return Database + * @return IDatabase|bool This returns false on failure if CONN_SILENCE_ERRORS is set + * @throws DBError If no live handle can be obtained and CONN_SILENCE_ERRORS is not set + * @throws DBAccessError If disable() was previously called + * @throws InvalidArgumentException */ public function getConnection( $i, $groups = [], $domain = false, $flags = 0 ); /** - * Mark a foreign connection as being available for reuse under a different DB domain + * Get a live handle for a server index + * + * This is a simpler version of getConnection() that does not accept virtual server + * indexes (e.g. DB_MASTER/DB_REPLICA), does not assure that master DB handles have + * read-only mode when there is high replication lag, and can only trigger attempts + * to connect to a single server (the one with the specified server index). + * + * @see ILoadBalancer::getConnection() + * + * @param int $i Specific server index + * @param string $domain Resolved DB domain + * @param int $flags Bitfield of class CONN_* constants + * @return IDatabase|bool + */ + public function getServerConnection( $i, $domain, $flags = 0 ); + + /** + * Mark a live handle as being available for reuse under a different database domain + * + * This mechanism is reference-counted, and must be called the same number of times as + * getConnection() to work. Never call this on handles acquired via getConnectionRef(), + * getLazyConnectionRef(), and getMaintenanceConnectionRef(), as they already manage + * the logic of calling this method when they fall out of scope in PHP. * - * This mechanism is reference-counted, and must be called the same number of times - * as getConnection() to work. + * @see ILoadBalancer::getConnection() * * @param IDatabase $conn - * @throws InvalidArgumentException + * @throws LogicException */ public function reuseConnection( IDatabase $conn ); /** - * Get a database connection handle reference - * - * The handle's methods simply wrap those of a Database handle + * Get a live database handle reference for a real or virtual (DB_MASTER/DB_REPLICA) server index * * The CONN_TRX_AUTOCOMMIT flag is ignored for databases with ATTR_DB_LEVEL_LOCKING - * (e.g. sqlite) in order to avoid deadlocks. ILoadBalancer::getServerAttributes() + * (e.g. sqlite) in order to avoid deadlocks. getServerAttributes() * can be used to check such flags beforehand. Avoid the use of begin() or startAtomic() * on any CONN_TRX_AUTOCOMMIT connections. * * @see ILoadBalancer::getConnection() for parameter information * * @param int $i Server index or DB_MASTER/DB_REPLICA - * @param array|string|bool $groups Query group(s), or false for the generic reader - * @param string|bool $domain Domain ID, or false for the current domain + * @param string[]|string $groups Query group(s) in preference order; [] for the default group + * @param string|bool $domain DB domain ID or false for the local domain * @param int $flags Bitfield of CONN_* class constants (e.g. CONN_TRX_AUTOCOMMIT) * @return DBConnRef */ public function getConnectionRef( $i, $groups = [], $domain = false, $flags = 0 ); /** - * Get a database connection handle reference without connecting yet + * Get a database handle reference for a real or virtual (DB_MASTER/DB_REPLICA) server index * - * The handle's methods simply wrap those of a Database handle + * The handle's methods simply proxy to those of an underlying IDatabase handle which + * takes care of the actual connection and query logic. * * The CONN_TRX_AUTOCOMMIT flag is ignored for databases with ATTR_DB_LEVEL_LOCKING - * (e.g. sqlite) in order to avoid deadlocks. ILoadBalancer::getServerAttributes() + * (e.g. sqlite) in order to avoid deadlocks. getServerAttributes() * can be used to check such flags beforehand. Avoid the use of begin() or startAtomic() * on any CONN_TRX_AUTOCOMMIT connections. * * @see ILoadBalancer::getConnection() for parameter information * * @param int $i Server index or DB_MASTER/DB_REPLICA - * @param array|string|bool $groups Query group(s), or false for the generic reader - * @param string|bool $domain Domain ID, or false for the current domain + * @param string[]|string $groups Query group(s) in preference order; [] for the default group + * @param string|bool $domain DB domain ID or false for the local domain * @param int $flags Bitfield of CONN_* class constants (e.g. CONN_TRX_AUTOCOMMIT) * @return DBConnRef */ public function getLazyConnectionRef( $i, $groups = [], $domain = false, $flags = 0 ); /** - * Get a maintenance database connection handle reference for migrations and schema changes + * Get a live database handle for a real or virtual (DB_MASTER/DB_REPLICA) server index + * that can be used for data migrations and schema changes * - * The handle's methods simply wrap those of a Database handle + * The handle's methods simply proxy to those of an underlying IDatabase handle which + * takes care of the actual connection and query logic. * * The CONN_TRX_AUTOCOMMIT flag is ignored for databases with ATTR_DB_LEVEL_LOCKING - * (e.g. sqlite) in order to avoid deadlocks. ILoadBalancer::getServerAttributes() + * (e.g. sqlite) in order to avoid deadlocks. getServerAttributes() * can be used to check such flags beforehand. Avoid the use of begin() or startAtomic() * on any CONN_TRX_AUTOCOMMIT connections. * * @see ILoadBalancer::getConnection() for parameter information * * @param int $i Server index or DB_MASTER/DB_REPLICA - * @param array|string|bool $groups Query group(s), or false for the generic reader - * @param string|bool $domain Domain ID, or false for the current domain + * @param string[]|string $groups Query group(s) in preference order; [] for the default group + * @param string|bool $domain DB domain ID or false for the local domain * @param int $flags Bitfield of CONN_* class constants (e.g. CONN_TRX_AUTOCOMMIT) * @return MaintainableDBConnRef */ public function getMaintenanceConnectionRef( $i, $groups = [], $domain = false, $flags = 0 ); /** - * Open a connection to the server given by the specified index - * - * The index must be an actual index into the array. If a connection to the server is - * already open and not considered an "in use" foreign connection, this simply returns it. - * - * Avoid using CONN_TRX_AUTOCOMMIT for databases with ATTR_DB_LEVEL_LOCKING (e.g. sqlite) - * in order to avoid deadlocks. ILoadBalancer::getServerAttributes() can be used to check - * such flags beforehand. - * - * If the caller uses $domain or sets CONN_TRX_AUTOCOMMIT in $flags, then it must - * also call ILoadBalancer::reuseConnection() on the handle when finished using it. - * In all other cases, this is not necessary, though not harmful either. - * Avoid the use of begin() or startAtomic() on any such connections. + * Get the server index of the master server * - * @note This method throws DBAccessError if ILoadBalancer::disable() was called - * - * @param int $i Server index (does not support DB_MASTER/DB_REPLICA) - * @param string|bool $domain Domain ID, or false for the current domain - * @param int $flags Bitfield of CONN_* class constants (e.g. CONN_TRX_AUTOCOMMIT) - * @return Database|bool Returns false on errors - * @throws DBAccessError - */ - public function openConnection( $i, $domain = false, $flags = 0 ); - - /** * @return int */ public function getWriterIndex(); /** - * Returns true if the specified index is a valid server index + * Get the number of servers defined in configuration * - * @param int $i - * @return bool + * @return int */ - public function haveIndex( $i ); + public function getServerCount(); /** - * Returns true if the specified index is valid and has non-zero load + * Whether there are any replica servers configured * - * @param int $i - * @return bool + * This counts both servers using streaming replication from the master server and + * servers that just have a clone of the static dataset found on the master server + * + * @return int + * @since 1.34 */ - public function isNonZeroLoad( $i ); + public function hasReplicaServers(); /** - * Get the number of defined servers (not the number of open connections) + * Whether any replica servers use streaming replication from the master server + * + * Generally this is one less than getServerCount(), though it might otherwise + * return a lower number if some of the servers are configured with "is static". + * That flag is used when both the server has no active replication setup and the + * dataset is either read-only or occasionally updated out-of-band. For example, + * a script might import a new geographic information dataset each week by writing + * it to each server and later directing the application to use the new version. + * + * It is possible for some replicas to be configured with "is static" but not + * others, though it generally should either be set for all or none of the replicas. + * + * If this returns zero, this means that there is generally no reason to execute + * replication wait logic for session consistency and lag reduction. * * @return int + * @since 1.34 */ - public function getServerCount(); + public function hasStreamingReplicaServers(); /** * Get the host name or IP address of the server with the specified index @@ -361,7 +431,7 @@ interface ILoadBalancer { public function getServerName( $i ); /** - * Return the server info structure for a given index, or false if the index is invalid. + * Return the server info structure for a given index or false if the index is invalid. * @param int $i * @return array|bool * @since 1.31 @@ -385,11 +455,30 @@ interface ILoadBalancer { public function getServerAttributes( $i ); /** - * Get the current master position for chronology control purposes + * Get the current master replication position + * * @return DBMasterPos|bool Returns false if not applicable + * @throws DBError */ public function getMasterPos(); + /** + * Get the highest DB replication position for chronology control purposes + * + * If there is only a master server then this returns false. If replication is present + * and correctly configured, then this returns the highest replication position of any + * server with an open connection. That position can later be passed to waitFor() on a + * new load balancer instance to make sure that queries on the new connections see data + * at least as up-to-date as queries (prior to this method call) on the old connections. + * + * This can be useful for implementing session consistency, where the session + * will be resumed accross multiple HTTP requests or CLI script instances. + * + * @return DBMasterPos|bool Replication position or false if not applicable + * @since 1.34 + */ + public function getReplicaResumePos(); + /** * Disable this load balancer. All connections are closed, and any attempt to * open a new connection will result in a DBAccessError. @@ -414,18 +503,21 @@ interface ILoadBalancer { /** * Commit transactions on all open connections * @param string $fname Caller name + * @param int|null $owner ID of the calling instance (e.g. the LBFactory ID) * @throws DBExpectedError */ - public function commitAll( $fname = __METHOD__ ); + public function commitAll( $fname = __METHOD__, $owner = null ); /** * Run pre-commit callbacks and defer execution of post-commit callbacks * * Use this only for mutli-database commits * + * @param string $fname Caller name + * @param int|null $owner ID of the calling instance (e.g. the LBFactory ID) * @return int Number of pre-commit callbacks run (since 1.32) */ - public function finalizeMasterChanges(); + public function finalizeMasterChanges( $fname = __METHOD__, $owner = null ); /** * Perform all pre-commit checks for things like replication safety @@ -434,9 +526,11 @@ interface ILoadBalancer { * * @param array $options Includes: * - maxWriteDuration : max write query duration time in seconds + * @param string $fname Caller name + * @param int|null $owner ID of the calling instance (e.g. the LBFactory ID) * @throws DBTransactionError */ - public function approveMasterChanges( array $options ); + public function approveMasterChanges( array $options, $fname, $owner = null ); /** * Flush any master transaction snapshots and set DBO_TRX (if DBO_DEFAULT is set) @@ -447,38 +541,45 @@ interface ILoadBalancer { * - commitAll() * This allows for custom transaction rounds from any outer transaction scope. * - * @param string $fname + * @param string $fname Caller name + * @param int|null $owner ID of the calling instance (e.g. the LBFactory ID) * @throws DBExpectedError */ - public function beginMasterChanges( $fname = __METHOD__ ); + public function beginMasterChanges( $fname = __METHOD__, $owner = null ); /** * Issue COMMIT on all open master connections to flush changes and view snapshots * @param string $fname Caller name + * @param int|null $owner ID of the calling instance (e.g. the LBFactory ID) * @throws DBExpectedError */ - public function commitMasterChanges( $fname = __METHOD__ ); + public function commitMasterChanges( $fname = __METHOD__, $owner = null ); /** * Consume and run all pending post-COMMIT/ROLLBACK callbacks and commit dangling transactions * + * @param string $fname Caller name + * @param int|null $owner ID of the calling instance (e.g. the LBFactory ID) * @return Exception|null The first exception or null if there were none */ - public function runMasterTransactionIdleCallbacks(); + public function runMasterTransactionIdleCallbacks( $fname = __METHOD__, $owner = null ); /** * Run all recurring post-COMMIT/ROLLBACK listener callbacks * + * @param string $fname Caller name + * @param int|null $owner ID of the calling instance (e.g. the LBFactory ID) * @return Exception|null The first exception or null if there were none */ - public function runMasterTransactionListenerCallbacks(); + public function runMasterTransactionListenerCallbacks( $fname = __METHOD__, $owner = null ); /** * Issue ROLLBACK only on master, only if queries were done on connection * @param string $fname Caller name + * @param int|null $owner ID of the calling instance (e.g. the LBFactory ID) * @throws DBExpectedError */ - public function rollbackMasterChanges( $fname = __METHOD__ ); + public function rollbackMasterChanges( $fname = __METHOD__, $owner = null ); /** * Commit all replica DB transactions so as to flush any REPEATABLE-READ or SSI snapshots @@ -531,7 +632,7 @@ interface ILoadBalancer { /** * @note This method will trigger a DB connection if not yet done - * @param string|bool $domain Domain ID, or false for the current domain + * @param string|bool $domain DB domain ID or false for the local domain * @return bool Whether the database for generic connections this request is highly "lagged" */ public function getLaggedReplicaMode( $domain = false ); @@ -548,7 +649,7 @@ interface ILoadBalancer { /** * @note This method may trigger a DB connection if not yet done - * @param string|bool $domain Domain ID, or false for the current domain + * @param string|bool $domain DB domain ID or false for the local domain * @param IDatabase|null $conn DB master connection; used to avoid loops [optional] * @return string|bool Reason the master is read-only or false if it is not */ @@ -588,13 +689,13 @@ interface ILoadBalancer { public function forEachOpenReplicaConnection( $callback, array $params = [] ); /** - * Get the hostname and lag time of the most-lagged replica DB + * Get the hostname and lag time of the most-lagged replica server * * This is useful for maintenance scripts that need to throttle their updates. * May attempt to open connections to replica DBs on the default DB. If there is * no lag, the maximum lag will be reported as -1. * - * @param bool|string $domain Domain ID, or false for the default database + * @param bool|string $domain Domain ID or false for the default database * @return array ( host, max lag, index of max lagged host ) */ public function getMaxLag( $domain = false ); @@ -611,33 +712,20 @@ interface ILoadBalancer { */ public function getLagTimes( $domain = false ); - /** - * Get the lag in seconds for a given connection, or zero if this load - * balancer does not have replication enabled. - * - * This should be used in preference to Database::getLag() in cases where - * replication may not be in use, since there is no way to determine if - * replication is in use at the connection level without running - * potentially restricted queries such as SHOW SLAVE STATUS. Using this - * function instead of Database::getLag() avoids a fatal error in this - * case on many installations. - * - * @param IDatabase $conn - * @return int|bool Returns false on error - */ - public function safeGetLag( IDatabase $conn ); - /** * Wait for a replica DB to reach a specified master position * - * This will connect to the master to get an accurate position if $pos is not given + * If $conn is not a replica server connection, then this will return true. + * Otherwise, if $pos is not provided, this will connect to the master server + * to get an accurate position. * * @param IDatabase $conn Replica DB * @param DBMasterPos|bool $pos Master position; default: current position * @param int $timeout Timeout in seconds [optional] * @return bool Success + * @since 1.34 */ - public function safeWaitForMasterPos( IDatabase $conn, $pos = false, $timeout = 10 ); + public function waitForMasterPos( IDatabase $conn, $pos = false, $timeout = 10 ); /** * Set a callback via IDatabase::setTransactionListener() on diff --git a/includes/libs/rdbms/loadbalancer/LoadBalancer.php b/includes/libs/rdbms/loadbalancer/LoadBalancer.php index 51fda52148..1ef1d09b5f 100644 --- a/includes/libs/rdbms/loadbalancer/LoadBalancer.php +++ b/includes/libs/rdbms/loadbalancer/LoadBalancer.php @@ -28,6 +28,7 @@ use BagOStuff; use EmptyBagOStuff; use WANObjectCache; use ArrayUtils; +use LogicException; use UnexpectedValueException; use InvalidArgumentException; use RuntimeException; @@ -72,8 +73,6 @@ class LoadBalancer implements ILoadBalancer { /** @var array[] Map of (server index => server config array) */ private $servers; - /** @var float[] Map of (server index => weight) */ - private $genericLoads; /** @var array[] Map of (group => server index => weight) */ private $groupLoads; /** @var bool Whether to disregard replica DB lag as a factor in replica DB selection */ @@ -84,8 +83,10 @@ class LoadBalancer implements ILoadBalancer { private $loadMonitorConfig; /** @var string Alternate local DB domain instead of DatabaseDomain::getId() */ private $localDomainIdAlias; - /** @var int */ + /** @var int Amount of replication lag, in seconds, that is considered "high" */ private $maxLag; + /** @var string|null Default query group to use with getConnection() */ + private $defaultGroup; /** @var string Current server name */ private $hostname; @@ -101,35 +102,32 @@ class LoadBalancer implements ILoadBalancer { /** @var array[] Map of (name => callable) */ private $trxRecurringCallbacks = []; - /** @var Database DB connection object that caused a problem */ + /** @var Database Connection handle that caused a problem */ private $errorConnection; - /** @var int The generic (not query grouped) replica DB index */ - private $genericReadIndex = -1; - /** @var bool|DBMasterPos False if not set */ + /** @var int[] The group replica server indexes keyed by group */ + private $readIndexByGroup = []; + /** @var bool|DBMasterPos Replication sync position or false if not set */ private $waitForPos; /** @var bool Whether the generic reader fell back to a lagged replica DB */ private $laggedReplicaMode = false; - /** @var bool Whether the generic reader fell back to a lagged replica DB */ - private $allReplicasDownMode = false; /** @var string The last DB selection or connection error */ private $lastError = 'Unknown error'; - /** @var string|bool Reason the LB is read-only or false if not */ + /** @var string|bool Reason this instance is read-only or false if not */ private $readOnlyReason = false; - /** @var int Total connections opened */ - private $connsOpened = 0; + /** @var int Total number of new connections ever made with this instance */ + private $connectionCounter = 0; /** @var bool */ private $disabled = false; /** @var bool Whether any connection has been attempted yet */ private $connectionAttempted = false; - /** @var string|bool String if a requested DBO_TRX transaction round is active */ + /** @var int|null Integer ID of the managing LBFactory instance or null if none */ + private $ownerId; + /** @var string|bool Explicit DBO_TRX transaction round active or false if none */ private $trxRoundId = false; /** @var string Stage of the current transaction round in the transaction round life-cycle */ private $trxRoundStage = self::ROUND_CURSORY; - /** @var string|null */ - private $defaultGroup = null; - /** @var int Warn when this many connection are held */ const CONN_HELD_WARN_THRESHOLD = 10; @@ -137,7 +135,7 @@ class LoadBalancer implements ILoadBalancer { const MAX_LAG_DEFAULT = 6; /** @var int Default 'waitTimeout' when unspecified */ const MAX_WAIT_DEFAULT = 10; - /** @var int Seconds to cache master server read-only status */ + /** @var int Seconds to cache master DB server read-only status */ const TTL_CACHE_READONLY = 5; const KEY_LOCAL = 'local'; @@ -162,15 +160,27 @@ class LoadBalancer implements ILoadBalancer { const ROUND_ERROR = 'error'; public function __construct( array $params ) { - if ( !isset( $params['servers'] ) ) { - throw new InvalidArgumentException( __CLASS__ . ': missing "servers" parameter' ); + if ( !isset( $params['servers'] ) || !count( $params['servers'] ) ) { + throw new InvalidArgumentException( 'Missing or empty "servers" parameter' ); } - $this->servers = $params['servers']; - foreach ( $this->servers as $i => $server ) { + + $listKey = -1; + $this->servers = []; + $this->groupLoads = [ self::GROUP_GENERIC => [] ]; + foreach ( $params['servers'] as $i => $server ) { + if ( ++$listKey !== $i ) { + throw new UnexpectedValueException( 'List expected for "servers" parameter' ); + } if ( $i == 0 ) { - $this->servers[$i]['master'] = true; + $server['master'] = true; } else { - $this->servers[$i]['replica'] = true; + $server['replica'] = true; + } + $this->servers[$i] = $server; + $serverGroupLoads = [ self::GROUP_GENERIC => $server['load'] ]; + $serverGroupLoads += ( $server['groupLoads'] ?? [] ); + foreach ( $serverGroupLoads as $group => $ratio ) { + $this->groupLoads[$group][$i] = $ratio; } } @@ -181,17 +191,7 @@ class LoadBalancer implements ILoadBalancer { $this->waitTimeout = $params['waitTimeout'] ?? self::MAX_WAIT_DEFAULT; - $this->conns = [ - // Connection were transaction rounds may be applied - self::KEY_LOCAL => [], - self::KEY_FOREIGN_INUSE => [], - self::KEY_FOREIGN_FREE => [], - // Auto-committing counterpart connections that ignore transaction rounds - self::KEY_LOCAL_NOROUND => [], - self::KEY_FOREIGN_INUSE_NOROUND => [], - self::KEY_FOREIGN_FREE_NOROUND => [] - ]; - $this->genericLoads = []; + $this->conns = self::newTrackedConnectionsArray(); $this->waitForPos = false; $this->allowLagged = false; @@ -204,18 +204,6 @@ class LoadBalancer implements ILoadBalancer { $this->loadMonitorConfig = $params['loadMonitor'] ?? [ 'class' => 'LoadMonitorNull' ]; $this->loadMonitorConfig += [ 'lagWarnThreshold' => $this->maxLag ]; - foreach ( $params['servers'] as $i => $server ) { - $this->genericLoads[$i] = $server['load']; - if ( isset( $server['groupLoads'] ) ) { - foreach ( $server['groupLoads'] as $group => $ratio ) { - if ( !isset( $this->groupLoads[$group] ) ) { - $this->groupLoads[$group] = []; - } - $this->groupLoads[$group][$i] = $ratio; - } - } - } - $this->srvCache = $params['srvCache'] ?? new EmptyBagOStuff(); $this->wanCache = $params['wanCache'] ?? WANObjectCache::newEmpty(); $this->profiler = $params['profiler'] ?? null; @@ -248,7 +236,23 @@ class LoadBalancer implements ILoadBalancer { } } - $this->defaultGroup = $params['defaultGroup'] ?? null; + $group = $params['defaultGroup'] ?? self::GROUP_GENERIC; + $this->defaultGroup = isset( $this->groupLoads[$group] ) ? $group : self::GROUP_GENERIC; + + $this->ownerId = $params['ownerId'] ?? null; + } + + private static function newTrackedConnectionsArray() { + return [ + // Connection were transaction rounds may be applied + self::KEY_LOCAL => [], + self::KEY_FOREIGN_INUSE => [], + self::KEY_FOREIGN_FREE => [], + // Auto-committing counterpart connections that ignore transaction rounds + self::KEY_LOCAL_NOROUND => [], + self::KEY_FOREIGN_INUSE_NOROUND => [], + self::KEY_FOREIGN_FREE_NOROUND => [] + ]; } public function getLocalDomainID() { @@ -264,6 +268,83 @@ class LoadBalancer implements ILoadBalancer { return (string)$domain; } + /** + * Resolve $groups into a list of query groups defining as having database servers + * + * @param string[]|string|bool $groups Query group(s) in preference order, [], or false + * @param int $i Specific server index or DB_MASTER/DB_REPLICA + * @return string[] Non-empty group list in preference order with the default group appended + */ + private function resolveGroups( $groups, $i ) { + // If a specific replica server was specified, then $groups makes no sense + if ( $i > 0 && $groups !== [] && $groups !== false ) { + $list = implode( ', ', (array)$groups ); + throw new LogicException( "Query group(s) ($list) given with server index (#$i)" ); + } + + if ( $groups === [] || $groups === false || $groups === $this->defaultGroup ) { + $resolvedGroups = [ $this->defaultGroup ]; // common case + } elseif ( is_string( $groups ) && isset( $this->groupLoads[$groups] ) ) { + $resolvedGroups = [ $groups, $this->defaultGroup ]; + } elseif ( is_array( $groups ) ) { + $resolvedGroups = array_keys( array_flip( $groups ) + [ self::GROUP_GENERIC => 1 ] ); + } else { + $resolvedGroups = [ $this->defaultGroup ]; + } + + return $resolvedGroups; + } + + /** + * @param int $flags Bitfield of class CONN_* constants + * @param int $i Specific server index or DB_MASTER/DB_REPLICA + * @return int Sanitized bitfield + */ + private function sanitizeConnectionFlags( $flags, $i ) { + // Whether an outside caller is explicitly requesting the master database server + if ( $i === self::DB_MASTER || $i === $this->getWriterIndex() ) { + $flags |= self::CONN_INTENT_WRITABLE; + } + + if ( ( $flags & self::CONN_TRX_AUTOCOMMIT ) == self::CONN_TRX_AUTOCOMMIT ) { + // Callers use CONN_TRX_AUTOCOMMIT to bypass REPEATABLE-READ staleness without + // resorting to row locks (e.g. FOR UPDATE) or to make small out-of-band commits + // during larger transactions. This is useful for avoiding lock contention. + + // Master DB server attributes (should match those of the replica DB servers) + $attributes = $this->getServerAttributes( $this->getWriterIndex() ); + if ( $attributes[Database::ATTR_DB_LEVEL_LOCKING] ) { + // The RDBMS does not support concurrent writes (e.g. SQLite), so attempts + // to use separate connections would just cause self-deadlocks. Note that + // REPEATABLE-READ staleness is not an issue since DB-level locking means + // that transactions are Strict Serializable anyway. + $flags &= ~self::CONN_TRX_AUTOCOMMIT; + $type = $this->getServerType( $this->getWriterIndex() ); + $this->connLogger->info( __METHOD__ . ": CONN_TRX_AUTOCOMMIT disallowed ($type)" ); + } + } + + return $flags; + } + + /** + * @param IDatabase $conn + * @param int $flags + * @throws DBUnexpectedError + */ + private function enforceConnectionFlags( IDatabase $conn, $flags ) { + if ( ( $flags & self::CONN_TRX_AUTOCOMMIT ) == self::CONN_TRX_AUTOCOMMIT ) { + if ( $conn->trxLevel() ) { // sanity + throw new DBUnexpectedError( + $conn, + 'Handle requested with CONN_TRX_AUTOCOMMIT yet it has a transaction' + ); + } + + $conn->clearFlag( $conn::DBO_TRX ); // auto-commit mode + } + } + /** * Get a LoadMonitor instance * @@ -301,7 +382,7 @@ class LoadBalancer implements ILoadBalancer { # Unset excessively lagged servers foreach ( $lags as $i => $lag ) { - if ( $i != 0 ) { + if ( $i !== $this->getWriterIndex() ) { # How much lag this server nominally is allowed to have $maxServerLag = $this->servers[$i]['max lag'] ?? $this->maxLag; // default # Constrain that futher by $maxLag argument @@ -346,79 +427,66 @@ class LoadBalancer implements ILoadBalancer { } /** - * @param int $i - * @param array $groups + * Get the server index to use for a specified server index and query group list + * + * @param int $i Specific server index or DB_MASTER/DB_REPLICA + * @param string[] $groups Non-empty query group list in preference order * @param string|bool $domain - * @return int + * @return int A specific server index (replica DBs are checked for connectivity) */ - private function getConnectionIndex( $i, $groups, $domain ) { - // Check one "group" per default: the generic pool - $defaultGroups = $this->defaultGroup ? [ $this->defaultGroup ] : [ false ]; - - $groups = ( $groups === false || $groups === [] ) - ? $defaultGroups - : (array)$groups; - - if ( $i == self::DB_MASTER ) { + private function getConnectionIndex( $i, array $groups, $domain ) { + if ( $i === self::DB_MASTER ) { $i = $this->getWriterIndex(); - } elseif ( $i == self::DB_REPLICA ) { - # Try to find an available server in any the query groups (in order) + } elseif ( $i === self::DB_REPLICA ) { foreach ( $groups as $group ) { $groupIndex = $this->getReaderIndex( $group, $domain ); if ( $groupIndex !== false ) { - $i = $groupIndex; + $i = $groupIndex; // group connection succeeded break; } } + } elseif ( !isset( $this->servers[$i] ) ) { + throw new UnexpectedValueException( "Invalid server index index #$i" ); } - # Operation-based index - if ( $i == self::DB_REPLICA ) { - $this->lastError = 'Unknown error'; // reset error string - # Try the general server pool if $groups are unavailable. - $i = ( $groups === [ false ] ) - ? false // don't bother with this if that is what was tried above - : $this->getReaderIndex( false, $domain ); - # Couldn't find a working server in getReaderIndex()? - if ( $i === false ) { - $this->lastError = 'No working replica DB server: ' . $this->lastError; - // Throw an exception - $this->reportConnectionError(); - return null; // not reached - } + if ( $i === self::DB_REPLICA ) { + $this->lastError = 'Unknown error'; // set here in case of worse failure + $this->lastError = 'No working replica DB server: ' . $this->lastError; + $this->reportConnectionError(); + return null; // unreachable due to exception } return $i; } public function getReaderIndex( $group = false, $domain = false ) { - if ( count( $this->servers ) == 1 ) { + if ( $this->getServerCount() == 1 ) { // Skip the load balancing if there's only one server return $this->getWriterIndex(); - } elseif ( $group === false && $this->genericReadIndex >= 0 ) { - // A generic reader index was already selected and "waitForPos" was handled - return $this->genericReadIndex; } - if ( $group !== false ) { - // Use the server weight array for this load group - if ( isset( $this->groupLoads[$group] ) ) { - $loads = $this->groupLoads[$group]; - } else { - // No loads for this group, return false and the caller can use some other group - $this->connLogger->info( __METHOD__ . ": no loads for group $group" ); + $group = is_string( $group ) ? $group : self::GROUP_GENERIC; - return false; - } + $index = $this->getExistingReaderIndex( $group ); + if ( $index >= 0 ) { + // A reader index was already selected and "waitForPos" was handled + return $index; + } + + // Use the server weight array for this load group + if ( isset( $this->groupLoads[$group] ) ) { + $loads = $this->groupLoads[$group]; } else { - // Use the generic load group - $loads = $this->genericLoads; + $this->connLogger->info( __METHOD__ . ": no loads for group $group" ); + + return false; } // Scale the configured load ratios according to each server's load and state $this->getLoadMonitor()->scaleLoads( $loads, $domain ); // Pick a server to use, accounting for weights, load, lag, and "waitForPos" + $this->lazyLoadReplicationPositions(); // optimizes server candidate selection list( $i, $laggedReplicaMode ) = $this->pickReaderIndex( $loads, $domain ); if ( $i === false ) { // DB connection unsuccessful @@ -427,16 +495,16 @@ class LoadBalancer implements ILoadBalancer { // If data seen by queries is expected to reflect the transactions committed as of // or after a given replication position then wait for the DB to apply those changes - if ( $this->waitForPos && $i != $this->getWriterIndex() && !$this->doWait( $i ) ) { + if ( $this->waitForPos && $i !== $this->getWriterIndex() && !$this->doWait( $i ) ) { // Data will be outdated compared to what was expected $laggedReplicaMode = true; } - if ( $this->genericReadIndex < 0 && $this->genericLoads[$i] > 0 && $group === false ) { - // Cache the generic (ungrouped) reader index for future DB_REPLICA handles - $this->genericReadIndex = $i; - // Record if the generic reader index is in "lagged replica DB" mode - $this->laggedReplicaMode = ( $laggedReplicaMode || $this->laggedReplicaMode ); + // Cache the reader index for future DB_REPLICA handles + $this->setExistingReaderIndex( $group, $i ); + // Record whether the generic reader index is in "lagged replica DB" mode + if ( $group === self::GROUP_GENERIC && $laggedReplicaMode ) { + $this->laggedReplicaMode = true; } $serverName = $this->getServerName( $i ); @@ -445,10 +513,33 @@ class LoadBalancer implements ILoadBalancer { return $i; } + /** + * Get the server index chosen by the load balancer for use with the given query group + * + * @param string $group Query group; use false for the generic group + * @return int Server index or -1 if none was chosen + */ + protected function getExistingReaderIndex( $group ) { + return $this->readIndexByGroup[$group] ?? -1; + } + + /** + * Set the server index chosen by the load balancer for use with the given query group + * + * @param string $group Query group; use false for the generic group + * @param int $index The index of a specific server + */ + private function setExistingReaderIndex( $group, $index ) { + if ( $index < 0 ) { + throw new UnexpectedValueException( "Cannot set a negative read server index" ); + } + $this->readIndexByGroup[$group] = $index; + } + /** * @param array $loads List of server weights * @param string|bool $domain - * @return array (reader index, lagged replica mode) or false on failure + * @return array (reader index, lagged replica mode) or (false, false) on failure */ private function pickReaderIndex( array $loads, $domain = false ) { if ( $loads === [] ) { @@ -468,6 +559,7 @@ class LoadBalancer implements ILoadBalancer { } else { $i = false; if ( $this->waitForPos && $this->waitForPos->asOfTime() ) { + $this->replLogger->debug( __METHOD__ . ": replication positions detected" ); // "chronologyCallback" sets "waitForPos" for session consistency. // This triggers doWait() after connect, so it's especially good to // avoid lagged servers so as to avoid excessive delay in that method. @@ -479,7 +571,7 @@ class LoadBalancer implements ILoadBalancer { // Any server with less lag than it's 'max lag' param is preferable $i = $this->getRandomNonLagged( $currentLoads, $domain ); } - if ( $i === false && count( $currentLoads ) != 0 ) { + if ( $i === false && count( $currentLoads ) ) { // All replica DBs lagged. Switch to read-only mode $this->replLogger->error( __METHOD__ . ": all replica DBs lagged. Switch to read-only mode" ); @@ -500,7 +592,9 @@ class LoadBalancer implements ILoadBalancer { $serverName = $this->getServerName( $i ); $this->connLogger->debug( __METHOD__ . ": Using reader #$i: $serverName..." ); - $conn = $this->openConnection( $i, $domain ); + // Get a connection to this server without triggering other server connections + $flags = self::CONN_SILENCE_ERRORS; + $conn = $this->getServerConnection( $i, $domain, $flags ); if ( !$conn ) { $this->connLogger->warning( __METHOD__ . ": Failed connecting to $i/$domain" ); unset( $currentLoads[$i] ); // avoid this server next iteration @@ -531,7 +625,8 @@ class LoadBalancer implements ILoadBalancer { try { $this->waitForPos = $pos; // If a generic reader connection was already established, then wait now - if ( $this->genericReadIndex > 0 && !$this->doWait( $this->genericReadIndex ) ) { + $i = $this->getExistingReaderIndex( self::GROUP_GENERIC ); + if ( $i > 0 && !$this->doWait( $i ) ) { $this->laggedReplicaMode = true; } // Otherwise, wait until a connection is established in getReaderIndex() @@ -546,10 +641,10 @@ class LoadBalancer implements ILoadBalancer { try { $this->waitForPos = $pos; - $i = $this->genericReadIndex; + $i = $this->getExistingReaderIndex( self::GROUP_GENERIC ); if ( $i <= 0 ) { // Pick a generic replica DB if there isn't one yet - $readLoads = $this->genericLoads; + $readLoads = $this->groupLoads[self::GROUP_GENERIC]; unset( $readLoads[$this->getWriterIndex()] ); // replica DBs only $readLoads = array_filter( $readLoads ); // with non-zero load $i = ArrayUtils::pickRandom( $readLoads ); @@ -574,11 +669,11 @@ class LoadBalancer implements ILoadBalancer { $oldPos = $this->waitForPos; try { $this->waitForPos = $pos; - $serverCount = count( $this->servers ); + $serverCount = $this->getServerCount(); $ok = true; for ( $i = 1; $i < $serverCount; $i++ ) { - if ( $this->genericLoads[$i] > 0 ) { + if ( $this->groupLoads[self::GROUP_GENERIC][$i] > 0 ) { $start = microtime( true ); $ok = $this->doWait( $i, true, $timeout ) && $ok; $timeout -= intval( microtime( true ) - $start ); @@ -610,9 +705,13 @@ class LoadBalancer implements ILoadBalancer { public function getAnyOpenConnection( $i, $flags = 0 ) { $i = ( $i === self::DB_MASTER ) ? $this->getWriterIndex() : $i; + // Connection handles required to be in auto-commit mode use a separate connection + // pool since the main pool is effected by implicit and explicit transaction rounds $autocommit = ( ( $flags & self::CONN_TRX_AUTOCOMMIT ) == self::CONN_TRX_AUTOCOMMIT ); + $conn = false; foreach ( $this->conns as $connsByServer ) { + // Get the connection array server indexes to inspect if ( $i === self::DB_REPLICA ) { $indexes = array_keys( $connsByServer ); } else { @@ -620,25 +719,54 @@ class LoadBalancer implements ILoadBalancer { } foreach ( $indexes as $index ) { - foreach ( $connsByServer[$index] as $conn ) { - if ( !$conn->isOpen() ) { - continue; // some sort of error occured? - } - if ( !$autocommit || $conn->getLBInfo( 'autoCommitOnly' ) ) { - return $conn; - } + $conn = $this->pickAnyOpenConnection( $connsByServer[$index], $autocommit ); + if ( $conn ) { + break; } } } - return false; + if ( $conn ) { + $this->enforceConnectionFlags( $conn, $flags ); + } + + return $conn; } /** - * Wait for a given replica DB to catch up to the master pos stored in $this - * @param int $index Server index + * @param IDatabase[] $candidateConns + * @param bool $autocommit Whether to only look for auto-commit connections + * @return IDatabase|false An appropriate open connection or false if none found + */ + private function pickAnyOpenConnection( $candidateConns, $autocommit ) { + $conn = false; + + foreach ( $candidateConns as $candidateConn ) { + if ( !$candidateConn->isOpen() ) { + continue; // some sort of error occured? + } elseif ( + $autocommit && + ( + // Connection is transaction round aware + !$candidateConn->getLBInfo( 'autoCommitOnly' ) || + // Some sort of error left a transaction open? + $candidateConn->trxLevel() + ) + ) { + continue; // some sort of error left a transaction open? + } + + $conn = $candidateConn; + } + + return $conn; + } + + /** + * Wait for a given replica DB to catch up to the master pos stored in "waitForPos" + * @param int $index Specific server index * @param bool $open Check the server even if a new connection has to be made - * @param int|null $timeout Max seconds to wait; default is "waitTimeout" given to __construct() + * @param int|null $timeout Max seconds to wait; default is "waitTimeout" * @return bool */ protected function doWait( $index, $open = false, $timeout = null ) { @@ -663,7 +791,8 @@ class LoadBalancer implements ILoadBalancer { // Find a connection to wait on, creating one if needed and allowed $close = false; // close the connection afterwards - $conn = $this->getAnyOpenConnection( $index ); + $flags = self::CONN_SILENCE_ERRORS; + $conn = $this->getAnyOpenConnection( $index, $flags ); if ( !$conn ) { if ( !$open ) { $this->replLogger->debug( @@ -672,20 +801,20 @@ class LoadBalancer implements ILoadBalancer { ); return false; - } else { - $conn = $this->openConnection( $index, self::DOMAIN_ANY ); - if ( !$conn ) { - $this->replLogger->warning( - __METHOD__ . ': failed to connect to {dbserver}', - [ 'dbserver' => $server ] - ); + } + // Get a connection to this server without triggering other server connections + $conn = $this->getServerConnection( $index, self::DOMAIN_ANY, $flags ); + if ( !$conn ) { + $this->replLogger->warning( + __METHOD__ . ': failed to connect to {dbserver}', + [ 'dbserver' => $server ] + ); - return false; - } - // Avoid connection spam in waitForAll() when connections - // are made just for the sake of doing this lag check. - $close = true; + return false; } + // Avoid connection spam in waitForAll() when connections + // are made just for the sake of doing this lag check. + $close = true; } $this->replLogger->info( @@ -731,53 +860,82 @@ class LoadBalancer implements ILoadBalancer { } public function getConnection( $i, $groups = [], $domain = false, $flags = 0 ) { - if ( $i === null || $i === false ) { - throw new InvalidArgumentException( "Cannot connect without a server index" ); + $domain = $this->resolveDomainID( $domain ); + $groups = $this->resolveGroups( $groups, $i ); + $flags = $this->sanitizeConnectionFlags( $flags, $i ); + // If given DB_MASTER/DB_REPLICA, resolve it to a specific server index. Resolving + // DB_REPLICA might trigger getServerConnection() calls due to the getReaderIndex() + // connectivity checks or LoadMonitor::scaleLoads() server state cache regeneration. + // The use of getServerConnection() instead of getConnection() avoids infinite loops. + $serverIndex = $this->getConnectionIndex( $i, $groups, $domain ); + // Get an open connection to that server (might trigger a new connection) + $conn = $this->getServerConnection( $serverIndex, $domain, $flags ); + // Set master DB handles as read-only if there is high replication lag + if ( $serverIndex === $this->getWriterIndex() && $this->getLaggedReplicaMode( $domain ) ) { + $reason = ( $this->getExistingReaderIndex( self::GROUP_GENERIC ) >= 0 ) + ? 'The database is read-only until replication lag decreases.' + : 'The database is read-only until replica database servers becomes reachable.'; + $conn->setLBInfo( 'readOnlyReason', $reason ); } - $domain = $this->resolveDomainID( $domain ); - $masterOnly = ( $i == self::DB_MASTER || $i == $this->getWriterIndex() ); + return $conn; + } - if ( ( $flags & self::CONN_TRX_AUTOCOMMIT ) === self::CONN_TRX_AUTOCOMMIT ) { - // Assuming all servers are of the same type (or similar), which is overwhelmingly - // the case, use the master server information to get the attributes. The information - // for $i cannot be used since it might be DB_REPLICA, which might require connection - // attempts in order to be resolved into a real server index. - $attributes = $this->getServerAttributes( $this->getWriterIndex() ); - if ( $attributes[Database::ATTR_DB_LEVEL_LOCKING] ) { - // Callers sometimes want to (a) escape REPEATABLE-READ stateness without locking - // rows (e.g. FOR UPDATE) or (b) make small commits during a larger transactions - // to reduce lock contention. None of these apply for sqlite and using separate - // connections just causes self-deadlocks. - $flags &= ~self::CONN_TRX_AUTOCOMMIT; - $this->connLogger->info( __METHOD__ . - ': ignoring CONN_TRX_AUTOCOMMIT to avoid deadlocks.' ); + /** + * @param int $i Specific server index + * @param string $domain Resolved DB domain + * @param int $flags Bitfield of class CONN_* constants + * @return IDatabase|bool + * @throws InvalidArgumentException When the server index is invalid + */ + public function getServerConnection( $i, $domain, $flags = 0 ) { + // Number of connections made before getting the server index and handle + $priorConnectionsMade = $this->connectionCounter; + // Get an open connection to this server (might trigger a new connection) + $conn = $this->localDomain->equals( $domain ) + ? $this->getLocalConnection( $i, $flags ) + : $this->getForeignConnection( $i, $domain, $flags ); + // Throw an error or otherwise bail out if the connection attempt failed + if ( !( $conn instanceof IDatabase ) ) { + if ( ( $flags & self::CONN_SILENCE_ERRORS ) != self::CONN_SILENCE_ERRORS ) { + $this->reportConnectionError(); } - } - // Number of connections made before getting the server index and handle - $priorConnectionsMade = $this->connsOpened; - // Decide which server to use (might trigger new connections) - $serverIndex = $this->getConnectionIndex( $i, $groups, $domain ); - // Get an open connection to that server (might trigger a new connection) - $conn = $this->openConnection( $serverIndex, $domain, $flags ); - if ( !$conn ) { - $this->reportConnectionError(); - return null; // unreachable due to exception + return false; } // Profile any new connections caused by this method - if ( $this->connsOpened > $priorConnectionsMade ) { - $host = $conn->getServer(); - $dbname = $conn->getDBname(); - $this->trxProfiler->recordConnection( $host, $dbname, $masterOnly ); + if ( $this->connectionCounter > $priorConnectionsMade ) { + $this->trxProfiler->recordConnection( + $conn->getServer(), + $conn->getDBname(), + ( ( $flags & self::CONN_INTENT_WRITABLE ) == self::CONN_INTENT_WRITABLE ) + ); } - if ( $serverIndex == $this->getWriterIndex() ) { - // If the load balancer is read-only, perhaps due to replication lag, then master - // DB handles will reflect that. Note that Database::assertIsWritableMaster() takes - // care of replica DB handles whereas getReadOnlyReason() would cause infinite loops. - $conn->setLBInfo( 'readOnlyReason', $this->getReadOnlyReason( $domain, $conn ) ); + if ( !$conn->isOpen() ) { + $this->errorConnection = $conn; + // Connection was made but later unrecoverably lost for some reason. + // Do not return a handle that will just throw exceptions on use, but + // let the calling code, e.g. getReaderIndex(), try another server. + return false; + } + + // Make sure that flags like CONN_TRX_AUTOCOMMIT are respected by this handle + $this->enforceConnectionFlags( $conn, $flags ); + // Set master DB handles as read-only if the load balancer is configured as read-only + // or the master database server is running in server-side read-only mode. Note that + // replica DB handles are always read-only via Database::assertIsWritableMaster(). + // Read-only mode due to replication lag is *avoided* here to avoid recursion. + if ( $conn->getLBInfo( 'serverIndex' ) === $this->getWriterIndex() ) { + if ( $this->readOnlyReason !== false ) { + $conn->setLBInfo( 'readOnlyReason', $this->readOnlyReason ); + } elseif ( $this->masterRunningReadOnly( $domain, $conn ) ) { + $conn->setLBInfo( + 'readOnlyReason', + 'The master database server is running in read-only mode.' + ); + } } return $conn; @@ -803,7 +961,7 @@ class LoadBalancer implements ILoadBalancer { // Database instance to this method. Any caller passing in a DBConnRef is broken. $this->connLogger->error( __METHOD__ . ": got DBConnRef instance.\n" . - ( new RuntimeException() )->getTraceAsString() ); + ( new LogicException() )->getTraceAsString() ); return; } @@ -875,43 +1033,15 @@ class LoadBalancer implements ILoadBalancer { : self::DB_REPLICA; } + /** + * @param int $i + * @param string|bool $domain + * @param int $flags + * @return Database|bool Live database handle or false on failure + * @deprecated Since 1.34 Use getConnection() instead + */ public function openConnection( $i, $domain = false, $flags = 0 ) { - $domain = $this->resolveDomainID( $domain ); - - if ( !$this->connectionAttempted && $this->chronologyCallback ) { - // Load any "waitFor" positions before connecting so that doWait() is triggered - $this->connLogger->debug( __METHOD__ . ': calling initLB() before first connection.' ); - $this->connectionAttempted = true; - ( $this->chronologyCallback )( $this ); - } - - $conn = $this->localDomain->equals( $domain ) - ? $this->openLocalConnection( $i, $flags ) - : $this->openForeignConnection( $i, $domain, $flags ); - - if ( $conn instanceof IDatabase && !$conn->isOpen() ) { - // Connection was made but later unrecoverably lost for some reason. - // Do not return a handle that will just throw exceptions on use, - // but let the calling code (e.g. getReaderIndex) try another server. - $this->errorConnection = $conn; - $conn = false; - } - - if ( - $conn instanceof IDatabase && - ( ( $flags & self::CONN_TRX_AUTOCOMMIT ) == self::CONN_TRX_AUTOCOMMIT ) - ) { - if ( $conn->trxLevel() ) { // sanity - throw new DBUnexpectedError( - $conn, - 'Handle requested with CONN_TRX_AUTOCOMMIT yet it has a transaction' - ); - } - - $conn->clearFlag( $conn::DBO_TRX ); // auto-commit mode - } - - return $conn; + return $this->getConnection( $i, [], $domain, $flags | self::CONN_SILENCE_ERRORS ); } /** @@ -925,8 +1055,10 @@ class LoadBalancer implements ILoadBalancer { * @param int $i Server index * @param int $flags Class CONN_* constant bitfield * @return Database + * @throws InvalidArgumentException When the server index is invalid + * @throws UnexpectedValueException When the DB domain of the connection is corrupted */ - private function openLocalConnection( $i, $flags = 0 ) { + private function getLocalConnection( $i, $flags = 0 ) { // Connection handles required to be in auto-commit mode use a separate connection // pool since the main pool is effected by implicit and explicit transaction rounds $autoCommit = ( ( $flags & self::CONN_TRX_AUTOCOMMIT ) == self::CONN_TRX_AUTOCOMMIT ); @@ -935,11 +1067,8 @@ class LoadBalancer implements ILoadBalancer { if ( isset( $this->conns[$connKey][$i][0] ) ) { $conn = $this->conns[$connKey][$i][0]; } else { - if ( !isset( $this->servers[$i] ) || !is_array( $this->servers[$i] ) ) { - throw new InvalidArgumentException( "No server with index '$i'" ); - } // Open a new connection - $server = $this->servers[$i]; + $server = $this->getServerInfoStrict( $i ); $server['serverIndex'] = $i; $server['autoCommitOnly'] = $autoCommit; $conn = $this->reallyOpenConnection( $server, $this->localDomain ); @@ -990,8 +1119,10 @@ class LoadBalancer implements ILoadBalancer { * @param int $flags Class CONN_* constant bitfield * @return Database|bool Returns false on connection error * @throws DBError When database selection fails + * @throws InvalidArgumentException When the server index is invalid + * @throws UnexpectedValueException When the DB domain of the connection is corrupted */ - private function openForeignConnection( $i, $domain, $flags = 0 ) { + private function getForeignConnection( $i, $domain, $flags = 0 ) { $domainInstance = DatabaseDomain::newFromId( $domain ); // Connection handles required to be in auto-commit mode use a separate connection // pool since the main pool is effected by implicit and explicit transaction rounds @@ -1049,11 +1180,8 @@ class LoadBalancer implements ILoadBalancer { } if ( !$conn ) { - if ( !isset( $this->servers[$i] ) || !is_array( $this->servers[$i] ) ) { - throw new InvalidArgumentException( "No server with index '$i'" ); - } // Open a new connection - $server = $this->servers[$i]; + $server = $this->getServerInfoStrict( $i ); $server['serverIndex'] = $i; $server['foreignPoolRefCount'] = 0; $server['foreign'] = true; @@ -1095,14 +1223,9 @@ class LoadBalancer implements ILoadBalancer { * Test if the specified index represents an open connection * * @param int $index Server index - * @private * @return bool */ private function isOpen( $index ) { - if ( !is_int( $index ) ) { - return false; - } - return (bool)$this->getAnyOpenConnection( $index ); } @@ -1147,12 +1270,6 @@ class LoadBalancer implements ILoadBalancer { $masterName = $this->getServerName( $this->getWriterIndex() ); $server['clusterMasterHost'] = $masterName; - // Log when many connection are made on requests - if ( ++$this->connsOpened >= self::CONN_HELD_WARN_THRESHOLD ) { - $this->perfLogger->warning( __METHOD__ . ": " . - "{$this->connsOpened}+ connections made (master=$masterName)" ); - } - $server['srvCache'] = $this->srvCache; // Set loggers and profilers $server['connLogger'] = $this->connLogger; @@ -1171,6 +1288,15 @@ class LoadBalancer implements ILoadBalancer { // Create a live connection object try { $db = Database::factory( $server['type'], $server ); + // Log when many connection are made on requests + ++$this->connectionCounter; + $currentConnCount = $this->getCurrentConnectionCount(); + if ( $currentConnCount >= self::CONN_HELD_WARN_THRESHOLD ) { + $this->perfLogger->warning( + __METHOD__ . ": {connections}+ connections made (master={masterdb})", + [ 'connections' => $currentConnCount, 'masterdb' => $masterName ] + ); + } } catch ( DBConnectionError $e ) { // FIXME: This is probably the ugliest thing I have ever done to // PHP. I'm half-expecting it to segfault, just out of disgust. -- TS @@ -1193,9 +1319,22 @@ class LoadBalancer implements ILoadBalancer { } } + $this->lazyLoadReplicationPositions(); // session consistency + return $db; } + /** + * Make sure that any "waitForPos" positions are loaded and available to doWait() + */ + private function lazyLoadReplicationPositions() { + if ( !$this->connectionAttempted && $this->chronologyCallback ) { + $this->connectionAttempted = true; + ( $this->chronologyCallback )( $this ); // generally calls waitFor() + $this->connLogger->debug( __METHOD__ . ': executed chronology callback.' ); + } + } + /** * @throws DBConnectionError */ @@ -1231,20 +1370,48 @@ class LoadBalancer implements ILoadBalancer { return 0; } + /** + * Returns true if the specified index is a valid server index + * + * @param int $i + * @return bool + * @deprecated Since 1.34 + */ public function haveIndex( $i ) { return array_key_exists( $i, $this->servers ); } + /** + * Returns true if the specified index is valid and has non-zero load + * + * @param int $i + * @return bool + * @deprecated Since 1.34 + */ public function isNonZeroLoad( $i ) { - return array_key_exists( $i, $this->servers ) && $this->genericLoads[$i] != 0; + return ( isset( $this->servers[$i] ) && $this->groupLoads[self::GROUP_GENERIC][$i] > 0 ); } public function getServerCount() { return count( $this->servers ); } + public function hasReplicaServers() { + return ( $this->getServerCount() > 1 ); + } + + public function hasStreamingReplicaServers() { + foreach ( $this->servers as $i => $server ) { + if ( $i !== $this->getWriterIndex() && empty( $server['is static'] ) ) { + return true; + } + } + + return false; + } + public function getServerName( $i ) { - $name = $this->servers[$i]['hostName'] ?? $this->servers[$i]['host'] ?? ''; + $name = $this->servers[$i]['hostName'] ?? ( $this->servers[$i]['host'] ?? '' ); return ( $name != '' ) ? $name : 'localhost'; } @@ -1258,22 +1425,56 @@ class LoadBalancer implements ILoadBalancer { } public function getMasterPos() { - # If this entire request was served from a replica DB without opening a connection to the - # master (however unlikely that may be), then we can fetch the position from the replica DB. + $index = $this->getWriterIndex(); + + $conn = $this->getAnyOpenConnection( $index ); + if ( $conn ) { + return $conn->getMasterPos(); + } + + $conn = $this->getConnection( $index, self::CONN_SILENCE_ERRORS ); + if ( !$conn ) { + $this->reportConnectionError(); + return null; // unreachable due to exception + } + + try { + $pos = $conn->getMasterPos(); + } finally { + $this->closeConnection( $conn ); + } + + return $pos; + } + + public function getReplicaResumePos() { + // Get the position of any existing master server connection $masterConn = $this->getAnyOpenConnection( $this->getWriterIndex() ); - if ( !$masterConn ) { - $serverCount = count( $this->servers ); - for ( $i = 1; $i < $serverCount; $i++ ) { - $conn = $this->getAnyOpenConnection( $i ); - if ( $conn ) { - return $conn->getReplicaPos(); - } - } - } else { + if ( $masterConn ) { return $masterConn->getMasterPos(); } - return false; + // Get the highest position of any existing replica server connection + $highestPos = false; + $serverCount = $this->getServerCount(); + for ( $i = 1; $i < $serverCount; $i++ ) { + if ( !empty( $this->servers[$i]['is static'] ) ) { + continue; // server does not use replication + } + + $conn = $this->getAnyOpenConnection( $i ); + $pos = $conn ? $conn->getReplicaPos() : false; + if ( !$pos ) { + continue; // no open connection or could not get position + } + + $highestPos = $highestPos ?: $pos; + if ( $pos->hasReached( $highestPos ) ) { + $highestPos = $pos; + } + } + + return $highestPos; } public function disable() { @@ -1290,15 +1491,7 @@ class LoadBalancer implements ILoadBalancer { $conn->close(); } ); - $this->conns = [ - self::KEY_LOCAL => [], - self::KEY_FOREIGN_INUSE => [], - self::KEY_FOREIGN_FREE => [], - self::KEY_LOCAL_NOROUND => [], - self::KEY_FOREIGN_INUSE_NOROUND => [], - self::KEY_FOREIGN_FREE_NOROUND => [] - ]; - $this->connsOpened = 0; + $this->conns = self::newTrackedConnectionsArray(); } public function closeConnection( IDatabase $conn ) { @@ -1319,7 +1512,6 @@ class LoadBalancer implements ILoadBalancer { $this->connLogger->debug( __METHOD__ . ": closing connection to database $i at '$host'." ); unset( $this->conns[$type][$serverIndex][$i] ); - --$this->connsOpened; break 2; } } @@ -1328,13 +1520,14 @@ class LoadBalancer implements ILoadBalancer { $conn->close(); } - public function commitAll( $fname = __METHOD__ ) { - $this->commitMasterChanges( $fname ); + public function commitAll( $fname = __METHOD__, $owner = null ) { + $this->commitMasterChanges( $fname, $owner ); $this->flushMasterSnapshots( $fname ); $this->flushReplicaSnapshots( $fname ); } - public function finalizeMasterChanges() { + public function finalizeMasterChanges( $fname = __METHOD__, $owner = null ) { + $this->assertOwnership( $fname, $owner ); $this->assertTransactionRoundStage( [ self::ROUND_CURSORY, self::ROUND_FINALIZED ] ); $this->trxRoundStage = self::ROUND_ERROR; // "failed" until proven otherwise @@ -1358,7 +1551,8 @@ class LoadBalancer implements ILoadBalancer { return $total; } - public function approveMasterChanges( array $options ) { + public function approveMasterChanges( array $options, $fname = __METHOD__, $owner = null ) { + $this->assertOwnership( $fname, $owner ); $this->assertTransactionRoundStage( self::ROUND_FINALIZED ); $limit = $options['maxWriteDuration'] ?? 0; @@ -1391,7 +1585,8 @@ class LoadBalancer implements ILoadBalancer { $this->trxRoundStage = self::ROUND_APPROVED; } - public function beginMasterChanges( $fname = __METHOD__ ) { + public function beginMasterChanges( $fname = __METHOD__, $owner = null ) { + $this->assertOwnership( $fname, $owner ); if ( $this->trxRoundId !== false ) { throw new DBTransactionError( null, @@ -1415,7 +1610,8 @@ class LoadBalancer implements ILoadBalancer { $this->trxRoundStage = self::ROUND_CURSORY; } - public function commitMasterChanges( $fname = __METHOD__ ) { + public function commitMasterChanges( $fname = __METHOD__, $owner = null ) { + $this->assertOwnership( $fname, $owner ); $this->assertTransactionRoundStage( self::ROUND_APPROVED ); $failures = []; @@ -1453,7 +1649,8 @@ class LoadBalancer implements ILoadBalancer { $this->trxRoundStage = self::ROUND_COMMIT_CALLBACKS; } - public function runMasterTransactionIdleCallbacks() { + public function runMasterTransactionIdleCallbacks( $fname = __METHOD__, $owner = null ) { + $this->assertOwnership( $fname, $owner ); if ( $this->trxRoundStage === self::ROUND_COMMIT_CALLBACKS ) { $type = IDatabase::TRIGGER_COMMIT; } elseif ( $this->trxRoundStage === self::ROUND_ROLLBACK_CALLBACKS ) { @@ -1522,7 +1719,8 @@ class LoadBalancer implements ILoadBalancer { return $e; } - public function runMasterTransactionListenerCallbacks() { + public function runMasterTransactionListenerCallbacks( $fname = __METHOD__, $owner = null ) { + $this->assertOwnership( $fname, $owner ); if ( $this->trxRoundStage === self::ROUND_COMMIT_CALLBACKS ) { $type = IDatabase::TRIGGER_COMMIT; } elseif ( $this->trxRoundStage === self::ROUND_ROLLBACK_CALLBACKS ) { @@ -1549,7 +1747,9 @@ class LoadBalancer implements ILoadBalancer { return $e; } - public function rollbackMasterChanges( $fname = __METHOD__ ) { + public function rollbackMasterChanges( $fname = __METHOD__, $owner = null ) { + $this->assertOwnership( $fname, $owner ); + $restore = ( $this->trxRoundId !== false ); $this->trxRoundId = false; $this->trxRoundStage = self::ROUND_ERROR; // "failed" until proven otherwise @@ -1567,6 +1767,7 @@ class LoadBalancer implements ILoadBalancer { /** * @param string|string[] $stage + * @throws DBTransactionError */ private function assertTransactionRoundStage( $stage ) { $stages = (array)$stage; @@ -1585,6 +1786,20 @@ class LoadBalancer implements ILoadBalancer { } } + /** + * @param string $fname + * @param int|null $owner Owner ID of the caller + * @throws DBTransactionError + */ + private function assertOwnership( $fname, $owner ) { + if ( $this->ownerId !== null && $owner !== $this->ownerId ) { + throw new DBTransactionError( + null, + "$fname: LoadBalancer is owned by LBFactory #{$this->ownerId} (got '$owner')." + ); + } + } + /** * Make all DB servers with DBO_DEFAULT/DBO_TRX set join the transaction round * @@ -1619,7 +1834,7 @@ class LoadBalancer implements ILoadBalancer { } if ( $conn->getFlag( $conn::DBO_TRX ) ) { - $conn->setLBInfo( 'trxRoundId', false ); + $conn->setLBInfo( 'trxRoundId', null ); // remove the round ID } if ( $conn->getFlag( $conn::DBO_DEFAULT ) ) { @@ -1686,20 +1901,16 @@ class LoadBalancer implements ILoadBalancer { } public function getLaggedReplicaMode( $domain = false ) { - if ( - // Avoid recursion if there is only one DB - $this->getServerCount() > 1 && - // Avoid recursion if the (non-zero load) master DB was picked for generic reads - $this->genericReadIndex !== $this->getWriterIndex() && - // Stay in lagged replica mode during the load balancer instance lifetime - !$this->laggedReplicaMode - ) { + if ( $this->laggedReplicaMode ) { + return true; // stay in lagged replica mode + } + + if ( $this->hasStreamingReplicaServers() ) { try { - // Calling this method will set "laggedReplicaMode" as needed - $this->getReaderIndex( false, $domain ); + // Set "laggedReplicaMode" + $this->getReaderIndex( self::GROUP_GENERIC, $domain ); } catch ( DBConnectionError $e ) { - // Avoid expensive re-connect attempts and failures - $this->allReplicasDownMode = true; + // Sanity: avoid expensive re-connect attempts and failures $this->laggedReplicaMode = true; } } @@ -1723,16 +1934,12 @@ class LoadBalancer implements ILoadBalancer { public function getReadOnlyReason( $domain = false, IDatabase $conn = null ) { if ( $this->readOnlyReason !== false ) { return $this->readOnlyReason; - } elseif ( $this->getLaggedReplicaMode( $domain ) ) { - if ( $this->allReplicasDownMode ) { - return 'The database has been automatically locked ' . - 'until the replica database servers become available'; - } else { - return 'The database has been automatically locked ' . - 'while the replica database servers catch up to the master.'; - } } elseif ( $this->masterRunningReadOnly( $domain, $conn ) ) { - return 'The database master is running in read-only mode.'; + return 'The master database server is running in read-only mode.'; + } elseif ( $this->getLaggedReplicaMode( $domain ) ) { + return ( $this->getExistingReaderIndex( self::GROUP_GENERIC ) >= 0 ) + ? 'The database is read-only until replication lag decreases.' + : 'The database is read-only until a replica database server becomes reachable.'; } return false; @@ -1753,7 +1960,8 @@ class LoadBalancer implements ILoadBalancer { function () use ( $domain, $conn ) { $old = $this->trxProfiler->setSilenced( true ); try { - $dbw = $conn ?: $this->getConnection( self::DB_MASTER, [], $domain ); + $index = $this->getWriterIndex(); + $dbw = $conn ?: $this->getServerConnection( $index, $domain ); $readOnly = (int)$dbw->serverIsReadOnly(); if ( !$conn ) { $this->reuseConnection( $dbw ); @@ -1762,6 +1970,7 @@ class LoadBalancer implements ILoadBalancer { $readOnly = 0; } $this->trxProfiler->setSilenced( $old ); + return $readOnly; }, [ 'pcTTL' => $cache::TTL_PROC_LONG, 'busyValue' => 0 ] @@ -1823,21 +2032,33 @@ class LoadBalancer implements ILoadBalancer { } } + /** + * @return int + */ + private function getCurrentConnectionCount() { + $count = 0; + foreach ( $this->conns as $connsByServer ) { + foreach ( $connsByServer as $serverConns ) { + $count += count( $serverConns ); + } + } + + return $count; + } + public function getMaxLag( $domain = false ) { - $maxLag = -1; $host = ''; + $maxLag = -1; $maxIndex = 0; - if ( $this->getServerCount() <= 1 ) { - return [ $host, $maxLag, $maxIndex ]; // no replication = no lag - } - - $lagTimes = $this->getLagTimes( $domain ); - foreach ( $lagTimes as $i => $lag ) { - if ( $this->genericLoads[$i] > 0 && $lag > $maxLag ) { - $maxLag = $lag; - $host = $this->servers[$i]['host']; - $maxIndex = $i; + if ( $this->hasReplicaServers() ) { + $lagTimes = $this->getLagTimes( $domain ); + foreach ( $lagTimes as $i => $lag ) { + if ( $this->groupLoads[self::GROUP_GENERIC][$i] > 0 && $lag > $maxLag ) { + $maxLag = $lag; + $host = $this->getServerInfoStrict( $i, 'host' ); + $maxIndex = $i; + } } } @@ -1845,7 +2066,7 @@ class LoadBalancer implements ILoadBalancer { } public function getLagTimes( $domain = false ) { - if ( $this->getServerCount() <= 1 ) { + if ( !$this->hasReplicaServers() ) { return [ $this->getWriterIndex() => 0 ]; // no replication = no lag } @@ -1862,15 +2083,32 @@ class LoadBalancer implements ILoadBalancer { return $this->getLoadMonitor()->getLagTimes( $indexesWithLag, $domain ) + $knownLagTimes; } + /** + * Get the lag in seconds for a given connection, or zero if this load + * balancer does not have replication enabled. + * + * This should be used in preference to Database::getLag() in cases where + * replication may not be in use, since there is no way to determine if + * replication is in use at the connection level without running + * potentially restricted queries such as SHOW SLAVE STATUS. Using this + * function instead of Database::getLag() avoids a fatal error in this + * case on many installations. + * + * @param IDatabase $conn + * @return int|bool Returns false on error + * @deprecated Since 1.34 Use IDatabase::getLag() instead + */ public function safeGetLag( IDatabase $conn ) { - if ( $this->getServerCount() <= 1 ) { - return 0; - } else { - return $conn->getLag(); + if ( $conn->getLBInfo( 'is static' ) ) { + return 0; // static dataset + } elseif ( $conn->getLBInfo( 'serverIndex' ) == $this->getWriterIndex() ) { + return 0; // this is the master } + + return $conn->getLag(); } - public function safeWaitForMasterPos( IDatabase $conn, $pos = false, $timeout = null ) { + public function waitForMasterPos( IDatabase $conn, $pos = false, $timeout = null ) { $timeout = max( 1, $timeout ?: $this->waitTimeout ); if ( $this->getServerCount() <= 1 || !$conn->getLBInfo( 'replica' ) ) { @@ -1879,11 +2117,13 @@ class LoadBalancer implements ILoadBalancer { if ( !$pos ) { // Get the current master position, opening a connection if needed - $masterConn = $this->getAnyOpenConnection( $this->getWriterIndex() ); + $index = $this->getWriterIndex(); + $flags = self::CONN_SILENCE_ERRORS; + $masterConn = $this->getAnyOpenConnection( $index, $flags ); if ( $masterConn ) { $pos = $masterConn->getMasterPos(); } else { - $masterConn = $this->openConnection( $this->getWriterIndex(), self::DOMAIN_ANY ); + $masterConn = $this->getServerConnection( $index, self::DOMAIN_ANY, $flags ); if ( !$masterConn ) { throw new DBReplicationWaitError( null, @@ -1896,12 +2136,15 @@ class LoadBalancer implements ILoadBalancer { } if ( $pos instanceof DBMasterPos ) { + $start = microtime( true ); $result = $conn->masterPosWait( $pos, $timeout ); + $seconds = max( microtime( true ) - $start, 0 ); if ( $result == -1 || is_null( $result ) ) { - $msg = __METHOD__ . ': timed out waiting on {host} pos {pos}'; + $msg = __METHOD__ . ': timed out waiting on {host} pos {pos} [{seconds}s]'; $this->replLogger->warning( $msg, [ 'host' => $conn->getServer(), 'pos' => $pos, + 'seconds' => round( $seconds, 6 ), 'trace' => ( new RuntimeException() )->getTraceAsString() ] ); $ok = false; @@ -1923,6 +2166,22 @@ class LoadBalancer implements ILoadBalancer { return $ok; } + /** + * Wait for a replica DB to reach a specified master position + * + * This will connect to the master to get an accurate position if $pos is not given + * + * @param IDatabase $conn Replica DB + * @param DBMasterPos|bool $pos Master position; default: current position + * @param int $timeout Timeout in seconds [optional] + * @return bool Success + * @since 1.28 + * @deprecated Since 1.34 Use waitForMasterPos() instead + */ + public function safeWaitForMasterPos( IDatabase $conn, $pos = false, $timeout = null ) { + return $this->waitForMasterPos( $conn, $pos, $timeout ); + } + public function setTransactionListener( $name, callable $callback = null ) { if ( $callback ) { $this->trxRecurringCallbacks[$name] = $callback; @@ -2005,6 +2264,28 @@ class LoadBalancer implements ILoadBalancer { } } + /** + * @param int $i Server index + * @param string|null $field Server index field [optional] + * @return array|mixed + * @throws InvalidArgumentException + */ + private function getServerInfoStrict( $i, $field = null ) { + if ( !isset( $this->servers[$i] ) || !is_array( $this->servers[$i] ) ) { + throw new InvalidArgumentException( "No server with index '$i'" ); + } + + if ( $field !== null ) { + if ( !array_key_exists( $field, $this->servers[$i] ) ) { + throw new InvalidArgumentException( "No field '$field' in server index '$i'" ); + } + + return $this->servers[$i][$field]; + } + + return $this->servers[$i]; + } + function __destruct() { // Avoid connection leaks for sanity $this->disable(); diff --git a/includes/libs/rdbms/loadbalancer/LoadBalancerSingle.php b/includes/libs/rdbms/loadbalancer/LoadBalancerSingle.php index fcddfcf139..4c68833e7e 100644 --- a/includes/libs/rdbms/loadbalancer/LoadBalancerSingle.php +++ b/includes/libs/rdbms/loadbalancer/LoadBalancerSingle.php @@ -86,6 +86,10 @@ class LoadBalancerSingle extends LoadBalancer { protected function reallyOpenConnection( array $server, DatabaseDomain $domain ) { return $this->db; } + + public function __destruct() { + // do nothing since the connection was injected + } } /** diff --git a/includes/libs/rdbms/loadmonitor/LoadMonitor.php b/includes/libs/rdbms/loadmonitor/LoadMonitor.php index 180baed753..19550f42a5 100644 --- a/includes/libs/rdbms/loadmonitor/LoadMonitor.php +++ b/includes/libs/rdbms/loadmonitor/LoadMonitor.php @@ -35,7 +35,7 @@ use WANObjectCache; */ class LoadMonitor implements ILoadMonitor { /** @var ILoadBalancer */ - protected $parent; + protected $lb; /** @var BagOStuff */ protected $srvCache; /** @var WANObjectCache */ @@ -64,7 +64,7 @@ class LoadMonitor implements ILoadMonitor { public function __construct( ILoadBalancer $lb, BagOStuff $srvCache, WANObjectCache $wCache, array $options = [] ) { - $this->parent = $lb; + $this->lb = $lb; $this->srvCache = $srvCache; $this->wanCache = $wCache; $this->replLogger = new NullLogger(); @@ -85,7 +85,7 @@ class LoadMonitor implements ILoadMonitor { if ( isset( $newScalesByServer[$i] ) ) { $weightByServer[$i] = $weight * $newScalesByServer[$i]; } else { // server recently added to config? - $host = $this->parent->getServerName( $i ); + $host = $this->lb->getServerName( $i ); $this->replLogger->error( __METHOD__ . ": host $host not in cache" ); } } @@ -96,7 +96,7 @@ class LoadMonitor implements ILoadMonitor { } protected function getServerStates( array $serverIndexes, $domain ) { - $writerIndex = $this->parent->getWriterIndex(); + $writerIndex = $this->lb->getWriterIndex(); if ( count( $serverIndexes ) == 1 && reset( $serverIndexes ) == $writerIndex ) { # Single server only, just return zero without caching return [ @@ -146,7 +146,7 @@ class LoadMonitor implements ILoadMonitor { $weightScales = []; $movAveRatio = $this->movingAveRatio; foreach ( $serverIndexes as $i ) { - if ( $i == $this->parent->getWriterIndex() ) { + if ( $i == $this->lb->getWriterIndex() ) { $lagTimes[$i] = 0; // master always has no lag $weightScales[$i] = 1.0; // nominal weight continue; @@ -154,12 +154,13 @@ class LoadMonitor implements ILoadMonitor { # Handles with open transactions are avoided since they might be subject # to REPEATABLE-READ snapshots, which could affect the lag estimate query. - $flags = ILoadBalancer::CONN_TRX_AUTOCOMMIT; - $conn = $this->parent->getAnyOpenConnection( $i, $flags ); + $flags = ILoadBalancer::CONN_TRX_AUTOCOMMIT | ILoadBalancer::CONN_SILENCE_ERRORS; + $conn = $this->lb->getAnyOpenConnection( $i, $flags ); if ( $conn ) { $close = false; // already open } else { - $conn = $this->parent->openConnection( $i, ILoadBalancer::DOMAIN_ANY, $flags ); + // Get a connection to this server without triggering other server connections + $conn = $this->lb->getServerConnection( $i, ILoadBalancer::DOMAIN_ANY, $flags ); $close = true; // new connection } @@ -170,7 +171,7 @@ class LoadMonitor implements ILoadMonitor { // Scale from 10% to 100% of nominal weight $weightScales[$i] = max( $newWeight, 0.10 ); - $host = $this->parent->getServerName( $i ); + $host = $this->lb->getServerName( $i ); if ( !$conn ) { $lagTimes[$i] = false; @@ -181,25 +182,21 @@ class LoadMonitor implements ILoadMonitor { continue; } - if ( $conn->getLBInfo( 'is static' ) ) { - $lagTimes[$i] = 0; - } else { - $lagTimes[$i] = $conn->getLag(); - if ( $lagTimes[$i] === false ) { - $this->replLogger->error( - __METHOD__ . ": host {db_server} is not replicating?", - [ 'db_server' => $host ] - ); - } elseif ( $lagTimes[$i] > $this->lagWarnThreshold ) { - $this->replLogger->warning( - "Server {host} has {lag} seconds of lag (>= {maxlag})", - [ - 'host' => $host, - 'lag' => $lagTimes[$i], - 'maxlag' => $this->lagWarnThreshold - ] - ); - } + $lagTimes[$i] = $conn->getLag(); + if ( $lagTimes[$i] === false ) { + $this->replLogger->error( + __METHOD__ . ": host {db_server} is not replicating?", + [ 'db_server' => $host ] + ); + } elseif ( $lagTimes[$i] > $this->lagWarnThreshold ) { + $this->replLogger->warning( + "Server {host} has {lag} seconds of lag (>= {maxlag})", + [ + 'host' => $host, + 'lag' => $lagTimes[$i], + 'maxlag' => $this->lagWarnThreshold + ] + ); } if ( $close ) { @@ -207,7 +204,7 @@ class LoadMonitor implements ILoadMonitor { # Note that the caller will pick one of these DBs and reconnect, # which is slightly inefficient, but this only matters for the lag # time cache miss cache, which is far less common that cache hits. - $this->parent->closeConnection( $conn ); + $this->lb->closeConnection( $conn ); } } @@ -239,7 +236,7 @@ class LoadMonitor implements ILoadMonitor { return $this->srvCache->makeGlobalKey( 'lag-times', self::VERSION, - $this->parent->getServerName( $this->parent->getWriterIndex() ), + $this->lb->getServerName( $this->lb->getWriterIndex() ), implode( '-', $serverIndexes ) ); } diff --git a/includes/libs/rdbms/loadmonitor/LoadMonitorMySQL.php b/includes/libs/rdbms/loadmonitor/LoadMonitorMySQL.php index dda980c118..5ae5bbdd9d 100644 --- a/includes/libs/rdbms/loadmonitor/LoadMonitorMySQL.php +++ b/includes/libs/rdbms/loadmonitor/LoadMonitorMySQL.php @@ -52,7 +52,7 @@ class LoadMonitorMySQL extends LoadMonitor { $res = $conn->query( 'SHOW STATUS', __METHOD__ ); $s = $res ? $conn->fetchObject( $res ) : false; if ( $s === false ) { - $host = $this->parent->getServerName( $index ); + $host = $this->lb->getServerName( $index ); $this->replLogger->error( __METHOD__ . ": could not get status for $host" ); } else { // https://dev.mysql.com/doc/refman/5.7/en/server-status-variables.html diff --git a/includes/libs/redis/RedisConnectionPool.php b/includes/libs/redis/RedisConnectionPool.php index 9a8086f529..eb645cc149 100644 --- a/includes/libs/redis/RedisConnectionPool.php +++ b/includes/libs/redis/RedisConnectionPool.php @@ -23,6 +23,7 @@ use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; /** * Helper class to manage Redis connections. @@ -81,7 +82,7 @@ class RedisConnectionPool implements LoggerAwareInterface { __CLASS__ . ' requires a Redis client library. ' . 'See https://www.mediawiki.org/wiki/Redis#Setup' ); } - $this->logger = $options['logger'] ?? new \Psr\Log\NullLogger(); + $this->logger = $options['logger'] ?? new NullLogger(); $this->connectTimeout = $options['connectTimeout']; $this->readTimeout = $options['readTimeout']; $this->persistent = $options['persistent']; @@ -98,10 +99,6 @@ class RedisConnectionPool implements LoggerAwareInterface { $this->id = $id; } - /** - * @param LoggerInterface $logger - * @return null - */ public function setLogger( LoggerInterface $logger ) { $this->logger = $logger; } @@ -169,10 +166,13 @@ class RedisConnectionPool implements LoggerAwareInterface { * @param string $server A hostname/port combination or the absolute path of a UNIX socket. * If a hostname is specified but no port, port 6379 will be used. * @param LoggerInterface|null $logger PSR-3 logger intance. [optional] - * @return RedisConnRef|bool Returns false on failure + * @return RedisConnRef|Redis|bool Returns false on failure * @throws MWException */ public function getConnection( $server, LoggerInterface $logger = null ) { + // The above @return also documents 'Redis' for convenience with IDEs. + // RedisConnRef uses PHP magic methods, which wouldn't be recognised. + $logger = $logger ?: $this->logger; // Check the listing "dead" servers which have had a connection errors. // Servers are marked dead for a limited period of time, to diff --git a/includes/libs/replacers/DoubleReplacer.php b/includes/libs/replacers/DoubleReplacer.php deleted file mode 100644 index 9d05e062a9..0000000000 --- a/includes/libs/replacers/DoubleReplacer.php +++ /dev/null @@ -1,46 +0,0 @@ -from = $from; - $this->to = $to; - $this->index = $index; - } - - /** - * @param array $matches - * @return mixed - */ - public function replace( array $matches ) { - return str_replace( $this->from, $this->to, $matches[$this->index] ); - } -} diff --git a/includes/libs/replacers/HashtableReplacer.php b/includes/libs/replacers/HashtableReplacer.php deleted file mode 100644 index 8247694a0d..0000000000 --- a/includes/libs/replacers/HashtableReplacer.php +++ /dev/null @@ -1,46 +0,0 @@ -table = $table; - $this->index = $index; - } - - /** - * @param array $matches - * @return mixed - */ - public function replace( array $matches ) { - return $this->table[$matches[$this->index]]; - } -} diff --git a/includes/libs/replacers/RegexlikeReplacer.php b/includes/libs/replacers/RegexlikeReplacer.php deleted file mode 100644 index bdc4dc0044..0000000000 --- a/includes/libs/replacers/RegexlikeReplacer.php +++ /dev/null @@ -1,49 +0,0 @@ -r = $r; - } - - /** - * @param array $matches - * @return string - */ - public function replace( array $matches ) { - $pairs = []; - foreach ( $matches as $i => $match ) { - $pairs["\$$i"] = $match; - } - - return strtr( $this->r, $pairs ); - } -} diff --git a/includes/libs/replacers/Replacer.php b/includes/libs/replacers/Replacer.php deleted file mode 100644 index 5425eedd74..0000000000 --- a/includes/libs/replacers/Replacer.php +++ /dev/null @@ -1,41 +0,0 @@ -buffer; - } - public function hasData() { return !empty( $this->buffer ); } diff --git a/includes/logging/BlockLogFormatter.php b/includes/logging/BlockLogFormatter.php index ddecf9ead5..ead290f062 100644 --- a/includes/logging/BlockLogFormatter.php +++ b/includes/logging/BlockLogFormatter.php @@ -127,7 +127,7 @@ class BlockLogFormatter extends LogFormatter { public function getPreloadTitles() { $title = $this->entry->getTarget(); // Preload user page for non-autoblocks - if ( substr( $title->getText(), 0, 1 ) !== '#' ) { + if ( substr( $title->getText(), 0, 1 ) !== '#' && $title->isValid() ) { return [ $title->getTalkPage() ]; } return []; diff --git a/includes/logging/DeleteLogFormatter.php b/includes/logging/DeleteLogFormatter.php index 8078e2e02c..3bc19ffd37 100644 --- a/includes/logging/DeleteLogFormatter.php +++ b/includes/logging/DeleteLogFormatter.php @@ -23,6 +23,8 @@ * @since 1.22 */ +use MediaWiki\Storage\RevisionRecord; + /** * This class formats delete log entries. * @@ -270,8 +272,6 @@ class DeleteLogFormatter extends LogFormatter { } } - $old = $this->parseBitField( $rawParams['6::ofield'] ); - $new = $this->parseBitField( $rawParams['7::nfield'] ); if ( !is_array( $rawParams['5::ids'] ) ) { $rawParams['5::ids'] = explode( ',', $rawParams['5::ids'] ); } @@ -279,19 +279,28 @@ class DeleteLogFormatter extends LogFormatter { $params = [ '::type' => $rawParams['4::type'], ':array:ids' => $rawParams['5::ids'], - ':assoc:old' => [ 'bitmask' => $old ], - ':assoc:new' => [ 'bitmask' => $new ], ]; static $fields = [ - Revision::DELETED_TEXT => 'content', - Revision::DELETED_COMMENT => 'comment', - Revision::DELETED_USER => 'user', - Revision::DELETED_RESTRICTED => 'restricted', + RevisionRecord::DELETED_TEXT => 'content', + RevisionRecord::DELETED_COMMENT => 'comment', + RevisionRecord::DELETED_USER => 'user', + RevisionRecord::DELETED_RESTRICTED => 'restricted', ]; - foreach ( $fields as $bit => $key ) { - $params[':assoc:old'][$key] = (bool)( $old & $bit ); - $params[':assoc:new'][$key] = (bool)( $new & $bit ); + + if ( isset( $rawParams['6::ofield'] ) ) { + $old = $this->parseBitField( $rawParams['6::ofield'] ); + $params[':assoc:old'] = [ 'bitmask' => $old ]; + foreach ( $fields as $bit => $key ) { + $params[':assoc:old'][$key] = (bool)( $old & $bit ); + } + } + if ( isset( $rawParams['7::nfield'] ) ) { + $new = $this->parseBitField( $rawParams['7::nfield'] ); + $params[':assoc:new'] = [ 'bitmask' => $new ]; + foreach ( $fields as $bit => $key ) { + $params[':assoc:new'][$key] = (bool)( $new & $bit ); + } } } elseif ( $subtype === 'restore' ) { $rawParams = $entry->getParameters(); diff --git a/includes/logging/LogFormatter.php b/includes/logging/LogFormatter.php index 3e942ae08d..e8dd8982b5 100644 --- a/includes/logging/LogFormatter.php +++ b/includes/logging/LogFormatter.php @@ -613,9 +613,13 @@ class LogFormatter { $this->setShowUserToolLinks( false ); $user = User::newFromName( $value ); - $value = Message::rawParam( $this->makeUserLink( $user ) ); - $this->setShowUserToolLinks( $saveLinkFlood ); + if ( !$user ) { + $value = $this->msg( 'empty-username' )->text(); + } else { + $value = Message::rawParam( $this->makeUserLink( $user ) ); + $this->setShowUserToolLinks( $saveLinkFlood ); + } break; case 'title': $title = Title::newFromText( $value ); diff --git a/includes/mail/EmailNotification.php b/includes/mail/EmailNotification.php index 0b77651bda..7361032937 100644 --- a/includes/mail/EmailNotification.php +++ b/includes/mail/EmailNotification.php @@ -407,7 +407,7 @@ class EmailNotification { * @param User $user * @param string $source */ - function compose( $user, $source ) { + private function compose( $user, $source ) { global $wgEnotifImpersonal; if ( !$this->composed_common ) { @@ -424,7 +424,7 @@ class EmailNotification { /** * Send any queued mails */ - function sendMails() { + private function sendMails() { global $wgEnotifImpersonal; if ( $wgEnotifImpersonal ) { $this->sendImpersonal( $this->mailTargets ); @@ -440,9 +440,8 @@ class EmailNotification { * @param User $watchingUser * @param string $source * @return Status - * @private */ - function sendPersonalised( $watchingUser, $source ) { + private function sendPersonalised( $watchingUser, $source ) { global $wgEnotifUseRealName; // From the PHP manual: // Note: The to parameter cannot be an address in the form of @@ -481,7 +480,7 @@ class EmailNotification { * @param MailAddress[] $addresses * @return Status|null */ - function sendImpersonal( $addresses ) { + private function sendImpersonal( $addresses ) { if ( empty( $addresses ) ) { return null; } diff --git a/includes/mail/MailAddress.php b/includes/mail/MailAddress.php index 63a114d2a9..1a5d08ac2a 100644 --- a/includes/mail/MailAddress.php +++ b/includes/mail/MailAddress.php @@ -71,7 +71,7 @@ class MailAddress { * Return formatted and quoted address to insert into SMTP headers * @return string */ - function toString() { + public function toString() { if ( !$this->address ) { return ''; } @@ -94,7 +94,7 @@ class MailAddress { return "$quoted <{$this->address}>"; } - function __toString() { + public function __toString() { return $this->toString(); } } diff --git a/includes/mail/UserMailer.php b/includes/mail/UserMailer.php index 5d7030bb62..47fa16f87f 100644 --- a/includes/mail/UserMailer.php +++ b/includes/mail/UserMailer.php @@ -64,7 +64,7 @@ class UserMailer { * * @return string */ - static function arrayToHeaderString( $headers, $endl = PHP_EOL ) { + private static function arrayToHeaderString( $headers, $endl = PHP_EOL ) { $strings = []; foreach ( $headers as $name => $value ) { // Prevent header injection by stripping newlines from value @@ -79,7 +79,7 @@ class UserMailer { * * @return string */ - static function makeMsgId() { + private static function makeMsgId() { global $wgSMTP, $wgServer; $domainId = WikiMap::getCurrentWikiDbDomain()->getId(); @@ -465,7 +465,7 @@ class UserMailer { * @param int $code Error number * @param string $string Error message */ - static function errorHandler( $code, $string ) { + private static function errorHandler( $code, $string ) { self::$mErrorString = preg_replace( '/^mail\(\)(\s*\[.*?\])?: /', '', $string ); } diff --git a/includes/media/TransformationalImageHandler.php b/includes/media/TransformationalImageHandler.php index dbeca0be38..95053cfdbd 100644 --- a/includes/media/TransformationalImageHandler.php +++ b/includes/media/TransformationalImageHandler.php @@ -231,7 +231,7 @@ abstract class TransformationalImageHandler extends ImageHandler { } // $scaler will return a MediaTransformError on failure, or false on success. - // If the scaler is succesful, it will have created a thumbnail at the destination + // If the scaler is successful, it will have created a thumbnail at the destination // path. if ( is_array( $scaler ) && is_callable( $scaler ) ) { // Allow subclasses to specify their own rendering methods. diff --git a/includes/objectcache/ObjectCache.php b/includes/objectcache/ObjectCache.php index c0adb51860..ffbc3783c4 100644 --- a/includes/objectcache/ObjectCache.php +++ b/includes/objectcache/ObjectCache.php @@ -323,7 +323,7 @@ class ObjectCache { * @throws UnexpectedValueException */ public static function newWANCacheFromParams( array $params ) { - global $wgCommandLineMode; + global $wgCommandLineMode, $wgSecretKey; $services = MediaWikiServices::getInstance(); $params['cache'] = self::newFromParams( $params['store'] ); @@ -334,6 +334,7 @@ class ObjectCache { // Let pre-emptive refreshes happen post-send on HTTP requests $params['asyncHandler'] = [ DeferredUpdates::class, 'addCallableUpdate' ]; } + $params['secret'] = $params['secret'] ?? $wgSecretKey; $class = $params['class']; return new $class( $params ); @@ -359,29 +360,19 @@ class ObjectCache { * @deprecated Since 1.28 Use MediaWikiServices::getInstance()->getMainWANObjectCache() */ public static function getMainWANInstance() { + wfDeprecated( __METHOD__, '1.28' ); return MediaWikiServices::getInstance()->getMainWANObjectCache(); } /** * Get the cache object for the main stash. * - * Stash objects are BagOStuff instances suitable for storing light - * weight data that is not canonically stored elsewhere (such as RDBMS). - * Stashes should be configured to propagate changes to all data-centers. - * - * Callers should be prepared for: - * - a) Writes to be slower in non-"primary" (e.g. HTTP GET/HEAD only) DCs - * - b) Reads to be eventually consistent, e.g. for get()/getMulti() - * In general, this means avoiding updates on idempotent HTTP requests and - * avoiding an assumption of perfect serializability (or accepting anomalies). - * Reads may be eventually consistent or data might rollback as nodes flap. - * Callers can use BagOStuff:READ_LATEST to see the latest available data. - * * @return BagOStuff * @since 1.26 * @deprecated Since 1.28 Use MediaWikiServices::getInstance()->getMainObjectStash() */ public static function getMainStashInstance() { + wfDeprecated( __METHOD__, '1.28' ); return MediaWikiServices::getInstance()->getMainObjectStash(); } diff --git a/includes/objectcache/SqlBagOStuff.php b/includes/objectcache/SqlBagOStuff.php index 5313ca4115..9226875e2a 100644 --- a/includes/objectcache/SqlBagOStuff.php +++ b/includes/objectcache/SqlBagOStuff.php @@ -36,25 +36,25 @@ use Wikimedia\WaitConditionLoop; * * @ingroup Cache */ -class SqlBagOStuff extends BagOStuff { +class SqlBagOStuff extends MediumSpecificBagOStuff { /** @var array[] (server index => server config) */ protected $serverInfos; /** @var string[] (server index => tag/host name) */ protected $serverTags; /** @var int */ protected $numServers; + /** @var int UNIX timestamp */ + protected $lastGarbageCollect = 0; /** @var int */ - protected $lastExpireAll = 0; + protected $purgePeriod = 10; /** @var int */ - protected $purgePeriod = 100; + protected $purgeLimit = 100; /** @var int */ protected $shards = 1; /** @var string */ protected $tableName = 'objectcache'; /** @var bool */ protected $replicaOnly = false; - /** @var int */ - protected $syncTimeout = 3; /** @var LoadBalancer|null */ protected $separateMainLB; @@ -65,6 +65,18 @@ class SqlBagOStuff extends BagOStuff { /** @var array Exceptions */ protected $connFailureErrors = []; + /** @var int */ + private static $GARBAGE_COLLECT_DELAY_SEC = 1; + + /** @var string */ + private static $OP_SET = 'set'; + /** @var string */ + private static $OP_ADD = 'add'; + /** @var string */ + private static $OP_TOUCH = 'touch'; + /** @var string */ + private static $OP_DELETE = 'delete'; + /** * Constructor. Parameters are: * - server: A server info structure in the format required by each @@ -77,12 +89,13 @@ class SqlBagOStuff extends BagOStuff { * when a cluster is replicated to another site (with different host names) * but each server has a corresponding replica in the other cluster. * - * - purgePeriod: The average number of object cache requests in between + * - purgePeriod: The average number of object cache writes in between * garbage collection operations, where expired entries * are removed from the database. Or in other words, the * reciprocal of the probability of purging on any given - * request. If this is set to zero, purging will never be - * done. + * write. If this is set to zero, purging will never be done. + * + * - purgeLimit: Maximum number of rows to purge at once. * * - tableName: The table name to use, default is "objectcache". * @@ -95,7 +108,7 @@ class SqlBagOStuff extends BagOStuff { * MySQL bugs 61735 * and 61736 . * - * - slaveOnly: Whether to only use replica DBs and avoid triggering + * - replicaOnly: Whether to only use replica DBs and avoid triggering * garbage collection logic of expired items. This only * makes sense if the primary DB is used and only if get() * calls will be used. This is used by ReplicatedBagOStuff. @@ -135,16 +148,17 @@ class SqlBagOStuff extends BagOStuff { if ( isset( $params['purgePeriod'] ) ) { $this->purgePeriod = intval( $params['purgePeriod'] ); } + if ( isset( $params['purgeLimit'] ) ) { + $this->purgeLimit = intval( $params['purgeLimit'] ); + } if ( isset( $params['tableName'] ) ) { $this->tableName = $params['tableName']; } if ( isset( $params['shards'] ) ) { $this->shards = intval( $params['shards'] ); } - if ( isset( $params['syncTimeout'] ) ) { - $this->syncTimeout = $params['syncTimeout']; - } - $this->replicaOnly = !empty( $params['slaveOnly'] ); + // Backwards-compatibility for < 1.34 + $this->replicaOnly = $params['replicaOnly'] ?? ( $params['slaveOnly'] ?? false ); } /** @@ -155,19 +169,20 @@ class SqlBagOStuff extends BagOStuff { * @throws MWException */ protected function getDB( $serverIndex ) { - if ( !isset( $this->conns[$serverIndex] ) ) { - if ( $serverIndex >= $this->numServers ) { - throw new MWException( __METHOD__ . ": Invalid server index \"$serverIndex\"" ); - } + if ( $serverIndex >= $this->numServers ) { + throw new MWException( __METHOD__ . ": Invalid server index \"$serverIndex\"" ); + } - # Don't keep timing out trying to connect for each call if the DB is down - if ( isset( $this->connFailureErrors[$serverIndex] ) - && ( time() - $this->connFailureTimes[$serverIndex] ) < 60 - ) { - throw $this->connFailureErrors[$serverIndex]; - } + # Don't keep timing out trying to connect for each call if the DB is down + if ( + isset( $this->connFailureErrors[$serverIndex] ) && + ( time() - $this->connFailureTimes[$serverIndex] ) < 60 + ) { + throw $this->connFailureErrors[$serverIndex]; + } - if ( $this->serverInfos ) { + if ( $this->serverInfos ) { + if ( !isset( $this->conns[$serverIndex] ) ) { // Use custom database defined by server connection info $info = $this->serverInfos[$serverIndex]; $type = $info['type'] ?? 'mysql'; @@ -175,25 +190,26 @@ class SqlBagOStuff extends BagOStuff { $this->logger->debug( __CLASS__ . ": connecting to $host" ); $db = Database::factory( $type, $info ); $db->clearFlag( DBO_TRX ); // auto-commit mode + $this->conns[$serverIndex] = $db; + } + $db = $this->conns[$serverIndex]; + } else { + // Use the main LB database + $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); + $index = $this->replicaOnly ? DB_REPLICA : DB_MASTER; + if ( $lb->getServerType( $lb->getWriterIndex() ) !== 'sqlite' ) { + // Keep a separate connection to avoid contention and deadlocks + $db = $lb->getConnection( $index, [], false, $lb::CONN_TRX_AUTOCOMMIT ); } else { - // Use the main LB database - $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); - $index = $this->replicaOnly ? DB_REPLICA : DB_MASTER; - if ( $lb->getServerType( $lb->getWriterIndex() ) !== 'sqlite' ) { - // Keep a separate connection to avoid contention and deadlocks - $db = $lb->getConnection( $index, [], false, $lb::CONN_TRX_AUTOCOMMIT ); - } else { - // However, SQLite has the opposite behavior due to DB-level locking. - // Stock sqlite MediaWiki installs use a separate sqlite cache DB instead. - $db = $lb->getConnection( $index ); - } + // However, SQLite has the opposite behavior due to DB-level locking. + // Stock sqlite MediaWiki installs use a separate sqlite cache DB instead. + $db = $lb->getConnection( $index ); } - - $this->logger->debug( sprintf( "Connection %s will be used for SqlBagOStuff", $db ) ); - $this->conns[$serverIndex] = $db; } - return $this->conns[$serverIndex]; + $this->logger->debug( sprintf( "Connection %s will be used for SqlBagOStuff", $db ) ); + + return $db; } /** @@ -250,7 +266,7 @@ class SqlBagOStuff extends BagOStuff { return false; } - public function getMulti( array $keys, $flags = 0 ) { + protected function doGetMulti( array $keys, $flags = 0 ) { $values = []; $blobs = $this->fetchBlobMulti( $keys ); @@ -261,7 +277,7 @@ class SqlBagOStuff extends BagOStuff { return $values; } - public function fetchBlobMulti( array $keys, $flags = 0 ) { + protected function fetchBlobMulti( array $keys, $flags = 0 ) { $values = []; // array of (key => value) $keysByTable = []; @@ -270,8 +286,6 @@ class SqlBagOStuff extends BagOStuff { $keysByTable[$serverIndex][$tableName][] = $key; } - $this->garbageCollect(); // expire old entries if any - $dataRows = []; foreach ( $keysByTable as $serverIndex => $serverKeys ) { try { @@ -304,7 +318,7 @@ class SqlBagOStuff extends BagOStuff { if ( isset( $dataRows[$key] ) ) { // HIT? $row = $dataRows[$key]; $this->debug( "get: retrieved data; expiry time is " . $row->exptime ); - $db = null; + $db = null; // in case of connection failure try { $db = $this->getDB( $row->serverIndex ); if ( $this->isExpired( $db, $row->exptime ) ) { // MISS @@ -323,59 +337,51 @@ class SqlBagOStuff extends BagOStuff { return $values; } - public function setMulti( array $data, $expiry = 0, $flags = 0 ) { - return $this->insertMulti( $data, $expiry, $flags, true ); + protected function doSetMulti( array $data, $exptime = 0, $flags = 0 ) { + return $this->modifyMulti( $data, $exptime, $flags, self::$OP_SET ); } - private function insertMulti( array $data, $expiry, $flags, $replace ) { + /** + * @param mixed[]|null[] $data Map of (key => new value or null) + * @param int $exptime UNIX timestamp, TTL in seconds, or 0 (no expiration) + * @param int $flags Bitfield of BagOStuff::WRITE_* constants + * @param string $op Cache operation + * @return bool + */ + private function modifyMulti( array $data, $exptime, $flags, $op ) { $keysByTable = []; foreach ( $data as $key => $value ) { list( $serverIndex, $tableName ) = $this->getTableByKey( $key ); $keysByTable[$serverIndex][$tableName][] = $key; } - $this->garbageCollect(); // expire old entries if any + $exptime = $this->convertToExpiry( $exptime ); $result = true; - $exptime = (int)$expiry; + /** @noinspection PhpUnusedLocalVariableInspection */ $silenceScope = $this->silenceTransactionProfiler(); foreach ( $keysByTable as $serverIndex => $serverKeys ) { - $db = null; + $db = null; // in case of connection failure try { $db = $this->getDB( $serverIndex ); + $this->occasionallyGarbageCollect( $db ); // expire old entries if any + $dbExpiry = $exptime ? $db->timestamp( $exptime ) : $this->getMaxDateTime( $db ); } catch ( DBError $e ) { $this->handleWriteError( $e, $db, $serverIndex ); $result = false; continue; } - if ( $exptime < 0 ) { - $exptime = 0; - } - - if ( $exptime == 0 ) { - $encExpiry = $this->getMaxDateTime( $db ); - } else { - $exptime = $this->convertToExpiry( $exptime ); - $encExpiry = $db->timestamp( $exptime ); - } foreach ( $serverKeys as $tableName => $tableKeys ) { - $rows = []; - foreach ( $tableKeys as $key ) { - $rows[] = [ - 'keyname' => $key, - 'value' => $db->encodeBlob( $this->serialize( $data[$key] ) ), - 'exptime' => $encExpiry, - ]; - } - try { - if ( $replace ) { - $db->replace( $tableName, [ 'keyname' ], $rows, __METHOD__ ); - } else { - $db->insert( $tableName, $rows, __METHOD__, [ 'IGNORE' ] ); - $result = ( $db->affectedRows() > 0 && $result ); - } + $result = $this->updateTableKeys( + $op, + $db, + $tableName, + $tableKeys, + $data, + $dbExpiry + ) && $result; } catch ( DBError $e ) { $this->handleWriteError( $e, $db, $serverIndex ); $result = false; @@ -391,36 +397,88 @@ class SqlBagOStuff extends BagOStuff { return $result; } - public function set( $key, $value, $exptime = 0, $flags = 0 ) { - $ok = $this->setMulti( [ $key => $value ], $exptime ); + /** + * @param string $op + * @param IDatabase $db + * @param string $table + * @param string[] $tableKeys Keys in $data to update + * @param mixed[]|null[] $data Map of (key => new value or null) + * @param string $dbExpiry DB-encoded expiry + * @return bool + * @throws DBError + * @throws InvalidArgumentException + */ + private function updateTableKeys( $op, $db, $table, $tableKeys, $data, $dbExpiry ) { + $success = true; - return $ok; + if ( $op === self::$OP_ADD ) { + $rows = []; + foreach ( $tableKeys as $key ) { + $rows[] = [ + 'keyname' => $key, + 'value' => $db->encodeBlob( $this->serialize( $data[$key] ) ), + 'exptime' => $dbExpiry + ]; + } + $db->delete( + $table, + [ + 'keyname' => $tableKeys, + 'exptime <= ' . $db->addQuotes( $db->timestamp() ) + ], + __METHOD__ + ); + $db->insert( $table, $rows, __METHOD__, [ 'IGNORE' ] ); + + $success = ( $db->affectedRows() == count( $rows ) ); + } elseif ( $op === self::$OP_SET ) { + $rows = []; + foreach ( $tableKeys as $key ) { + $rows[] = [ + 'keyname' => $key, + 'value' => $db->encodeBlob( $this->serialize( $data[$key] ) ), + 'exptime' => $dbExpiry + ]; + } + $db->replace( $table, [ 'keyname' ], $rows, __METHOD__ ); + } elseif ( $op === self::$OP_DELETE ) { + $db->delete( $table, [ 'keyname' => $tableKeys ], __METHOD__ ); + } elseif ( $op === self::$OP_TOUCH ) { + $db->update( + $table, + [ 'exptime' => $dbExpiry ], + [ + 'keyname' => $tableKeys, + 'exptime > ' . $db->addQuotes( $db->timestamp() ) + ], + __METHOD__ + ); + + $success = ( $db->affectedRows() == count( $tableKeys ) ); + } else { + throw new InvalidArgumentException( "Invalid operation '$op'" ); + } + + return $success; } - public function add( $key, $value, $exptime = 0, $flags = 0 ) { - $added = $this->insertMulti( [ $key => $value ], $exptime, $flags, false ); + protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) { + return $this->modifyMulti( [ $key => $value ], $exptime, $flags, self::$OP_SET ); + } - return $added; + public function add( $key, $value, $exptime = 0, $flags = 0 ) { + return $this->modifyMulti( [ $key => $value ], $exptime, $flags, self::$OP_ADD ); } protected function cas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) { list( $serverIndex, $tableName ) = $this->getTableByKey( $key ); - $db = null; + $exptime = $this->convertToExpiry( $exptime ); + + /** @noinspection PhpUnusedLocalVariableInspection */ $silenceScope = $this->silenceTransactionProfiler(); + $db = null; // in case of connection failure try { $db = $this->getDB( $serverIndex ); - $exptime = intval( $exptime ); - - if ( $exptime < 0 ) { - $exptime = 0; - } - - if ( $exptime == 0 ) { - $encExpiry = $this->getMaxDateTime( $db ); - } else { - $exptime = $this->convertToExpiry( $exptime ); - $encExpiry = $db->timestamp( $exptime ); - } // (T26425) use a replace if the db supports it instead of // delete/insert to avoid clashes with conflicting keynames $db->update( @@ -428,11 +486,14 @@ class SqlBagOStuff extends BagOStuff { [ 'keyname' => $key, 'value' => $db->encodeBlob( $this->serialize( $value ) ), - 'exptime' => $encExpiry + 'exptime' => $exptime + ? $db->timestamp( $exptime ) + : $this->getMaxDateTime( $db ) ], [ 'keyname' => $key, - 'value' => $db->encodeBlob( $casToken ) + 'value' => $db->encodeBlob( $casToken ), + 'exptime > ' . $db->addQuotes( $db->timestamp() ) ], __METHOD__ ); @@ -445,96 +506,51 @@ class SqlBagOStuff extends BagOStuff { return (bool)$db->affectedRows(); } - public function deleteMulti( array $keys, $flags = 0 ) { - $keysByTable = []; - foreach ( $keys as $key ) { - list( $serverIndex, $tableName ) = $this->getTableByKey( $key ); - $keysByTable[$serverIndex][$tableName][] = $key; - } - - $result = true; - $silenceScope = $this->silenceTransactionProfiler(); - foreach ( $keysByTable as $serverIndex => $serverKeys ) { - $db = null; - try { - $db = $this->getDB( $serverIndex ); - } catch ( DBError $e ) { - $this->handleWriteError( $e, $db, $serverIndex ); - $result = false; - continue; - } - - foreach ( $serverKeys as $tableName => $tableKeys ) { - try { - $db->delete( $tableName, [ 'keyname' => $tableKeys ], __METHOD__ ); - } catch ( DBError $e ) { - $this->handleWriteError( $e, $db, $serverIndex ); - $result = false; - } - - } - } - - if ( ( $flags & self::WRITE_SYNC ) == self::WRITE_SYNC ) { - $result = $this->waitForReplication() && $result; - } - - return $result; + protected function doDeleteMulti( array $keys, $flags = 0 ) { + return $this->modifyMulti( + array_fill_keys( $keys, null ), + 0, + $flags, + self::$OP_DELETE + ); } - public function delete( $key, $flags = 0 ) { - $ok = $this->deleteMulti( [ $key ], $flags ); - - return $ok; + protected function doDelete( $key, $flags = 0 ) { + return $this->modifyMulti( [ $key => null ], 0, $flags, self::$OP_DELETE ); } public function incr( $key, $step = 1 ) { list( $serverIndex, $tableName ) = $this->getTableByKey( $key ); - $db = null; + + $newCount = false; + /** @noinspection PhpUnusedLocalVariableInspection */ $silenceScope = $this->silenceTransactionProfiler(); + $db = null; // in case of connection failure try { $db = $this->getDB( $serverIndex ); - $step = intval( $step ); - $row = $db->selectRow( - $tableName, - [ 'value', 'exptime' ], - [ 'keyname' => $key ], - __METHOD__, - [ 'FOR UPDATE' ] - ); - if ( $row === false ) { - // Missing - return false; - } - $db->delete( $tableName, [ 'keyname' => $key ], __METHOD__ ); - if ( $this->isExpired( $db, $row->exptime ) ) { - // Expired, do not reinsert - return false; - } - - $oldValue = intval( $this->unserialize( $db->decodeBlob( $row->value ) ) ); - $newValue = $oldValue + $step; - $db->insert( + $encTimestamp = $db->addQuotes( $db->timestamp() ); + $db->update( $tableName, - [ - 'keyname' => $key, - 'value' => $db->encodeBlob( $this->serialize( $newValue ) ), - 'exptime' => $row->exptime - ], - __METHOD__, - [ 'IGNORE' ] + [ 'value = value + ' . (int)$step ], + [ 'keyname' => $key, "exptime > $encTimestamp" ], + __METHOD__ ); - - if ( $db->affectedRows() == 0 ) { - // Race condition. See T30611 - $newValue = false; + if ( $db->affectedRows() > 0 ) { + $newValue = $db->selectField( + $tableName, + 'value', + [ 'keyname' => $key, "exptime > $encTimestamp" ], + __METHOD__ + ); + if ( $this->isInteger( $newValue ) ) { + $newCount = (int)$newValue; + } } } catch ( DBError $e ) { $this->handleWriteError( $e, $db, $serverIndex ); - return false; } - return $newValue; + return $newCount; } public function merge( $key, callable $callback, $exptime = 0, $attempts = 10, $flags = 0 ) { @@ -546,40 +562,17 @@ class SqlBagOStuff extends BagOStuff { return $ok; } - public function changeTTL( $key, $exptime = 0, $flags = 0 ) { - list( $serverIndex, $tableName ) = $this->getTableByKey( $key ); - $db = null; - $silenceScope = $this->silenceTransactionProfiler(); - try { - $db = $this->getDB( $serverIndex ); - if ( $exptime == 0 ) { - $timestamp = $this->getMaxDateTime( $db ); - } else { - $timestamp = $db->timestamp( $this->convertToExpiry( $exptime ) ); - } - $db->update( - $tableName, - [ 'exptime' => $timestamp ], - [ 'keyname' => $key, 'exptime > ' . $db->addQuotes( $db->timestamp( time() ) ) ], - __METHOD__ - ); - if ( $db->affectedRows() == 0 ) { - $exists = (bool)$db->selectField( - $tableName, - 1, - [ 'keyname' => $key, 'exptime' => $timestamp ], - __METHOD__, - [ 'FOR UPDATE' ] - ); - - return $exists; - } - } catch ( DBError $e ) { - $this->handleWriteError( $e, $db, $serverIndex ); - return false; - } + protected function doChangeTTLMulti( array $keys, $exptime, $flags = 0 ) { + return $this->modifyMulti( + array_fill_keys( $keys, null ), + $exptime, + $flags, + self::$OP_TOUCH + ); + } - return true; + protected function doChangeTTL( $key, $exptime, $flags ) { + return $this->modifyMulti( [ $key => null ], $exptime, $flags, self::$OP_TOUCH ); } /** @@ -588,7 +581,10 @@ class SqlBagOStuff extends BagOStuff { * @return bool */ protected function isExpired( $db, $exptime ) { - return $exptime != $this->getMaxDateTime( $db ) && wfTimestamp( TS_UNIX, $exptime ) < time(); + return ( + $exptime != $this->getMaxDateTime( $db ) && + wfTimestamp( TS_UNIX, $exptime ) < time() + ); } /** @@ -603,104 +599,145 @@ class SqlBagOStuff extends BagOStuff { } } - protected function garbageCollect() { - if ( !$this->purgePeriod || $this->replicaOnly ) { - // Disabled - return; - } - // Only purge on one in every $this->purgePeriod requests. - if ( $this->purgePeriod !== 1 && mt_rand( 0, $this->purgePeriod - 1 ) ) { - return; - } - $now = time(); - // Avoid repeating the delete within a few seconds - if ( $now > ( $this->lastExpireAll + 1 ) ) { - $this->lastExpireAll = $now; - $this->expireAll(); + /** + * @param IDatabase $db + * @throws DBError + */ + protected function occasionallyGarbageCollect( IDatabase $db ) { + if ( + // Random purging is enabled + $this->purgePeriod && + // Only purge on one in every $this->purgePeriod writes + mt_rand( 0, $this->purgePeriod - 1 ) == 0 && + // Avoid repeating the delete within a few seconds + ( time() - $this->lastGarbageCollect ) > self::$GARBAGE_COLLECT_DELAY_SEC + ) { + $garbageCollector = function () use ( $db ) { + $this->deleteServerObjectsExpiringBefore( $db, time(), null, $this->purgeLimit ); + $this->lastGarbageCollect = time(); + }; + if ( $this->asyncHandler ) { + $this->lastGarbageCollect = time(); // avoid duplicate enqueues + ( $this->asyncHandler )( $garbageCollector ); + } else { + $garbageCollector(); + } } } public function expireAll() { - $this->deleteObjectsExpiringBefore( wfTimestampNow() ); + $this->deleteObjectsExpiringBefore( time() ); } - /** - * Delete objects from the database which expire before a certain date. - * @param string $timestamp - * @param bool|callable $progressCallback - * @return bool - */ - public function deleteObjectsExpiringBefore( $timestamp, $progressCallback = false ) { + public function deleteObjectsExpiringBefore( + $timestamp, + callable $progress = null, + $limit = INF + ) { + /** @noinspection PhpUnusedLocalVariableInspection */ $silenceScope = $this->silenceTransactionProfiler(); - for ( $serverIndex = 0; $serverIndex < $this->numServers; $serverIndex++ ) { - $db = null; + + $serverIndexes = range( 0, $this->numServers - 1 ); + shuffle( $serverIndexes ); + + $ok = true; + + $keysDeletedCount = 0; + foreach ( $serverIndexes as $numServersDone => $serverIndex ) { + $db = null; // in case of connection failure try { $db = $this->getDB( $serverIndex ); - $dbTimestamp = $db->timestamp( $timestamp ); - $totalSeconds = false; - $baseConds = [ 'exptime < ' . $db->addQuotes( $dbTimestamp ) ]; - for ( $i = 0; $i < $this->shards; $i++ ) { - $maxExpTime = false; - while ( true ) { - $conds = $baseConds; - if ( $maxExpTime !== false ) { - $conds[] = 'exptime >= ' . $db->addQuotes( $maxExpTime ); - } - $rows = $db->select( - $this->getTableNameByShard( $i ), - [ 'keyname', 'exptime' ], - $conds, - __METHOD__, - [ 'LIMIT' => 100, 'ORDER BY' => 'exptime' ] ); - if ( $rows === false || !$rows->numRows() ) { - break; - } - $keys = []; - $row = $rows->current(); - $minExpTime = $row->exptime; - if ( $totalSeconds === false ) { - $totalSeconds = wfTimestamp( TS_UNIX, $timestamp ) - - wfTimestamp( TS_UNIX, $minExpTime ); - } - foreach ( $rows as $row ) { - $keys[] = $row->keyname; - $maxExpTime = $row->exptime; - } - - $db->delete( - $this->getTableNameByShard( $i ), - [ - 'exptime >= ' . $db->addQuotes( $minExpTime ), - 'exptime < ' . $db->addQuotes( $dbTimestamp ), - 'keyname' => $keys - ], - __METHOD__ ); - - if ( $progressCallback ) { - if ( intval( $totalSeconds ) === 0 ) { - $percent = 0; - } else { - $remainingSeconds = wfTimestamp( TS_UNIX, $timestamp ) - - wfTimestamp( TS_UNIX, $maxExpTime ); - if ( $remainingSeconds > $totalSeconds ) { - $totalSeconds = $remainingSeconds; - } - $processedSeconds = $totalSeconds - $remainingSeconds; - $percent = ( $i + $processedSeconds / $totalSeconds ) - / $this->shards * 100; - } - $percent = ( $percent / $this->numServers ) - + ( $serverIndex / $this->numServers * 100 ); - call_user_func( $progressCallback, $percent ); - } - } - } + $this->deleteServerObjectsExpiringBefore( + $db, + $timestamp, + $progress, + $limit, + $numServersDone, + $keysDeletedCount + ); } catch ( DBError $e ) { $this->handleWriteError( $e, $db, $serverIndex ); - return false; + $ok = false; } } - return true; + + return $ok; + } + + /** + * @param IDatabase $db + * @param string|int $timestamp + * @param callable|null $progressCallback + * @param int $limit + * @param int $serversDoneCount + * @param int &$keysDeletedCount + * @throws DBError + */ + private function deleteServerObjectsExpiringBefore( + IDatabase $db, + $timestamp, + $progressCallback, + $limit, + $serversDoneCount = 0, + &$keysDeletedCount = 0 + ) { + $cutoffUnix = wfTimestamp( TS_UNIX, $timestamp ); + $shardIndexes = range( 0, $this->shards - 1 ); + shuffle( $shardIndexes ); + + foreach ( $shardIndexes as $numShardsDone => $shardIndex ) { + $continue = null; // last exptime + $lag = null; // purge lag + do { + $res = $db->select( + $this->getTableNameByShard( $shardIndex ), + [ 'keyname', 'exptime' ], + array_merge( + [ 'exptime < ' . $db->addQuotes( $db->timestamp( $cutoffUnix ) ) ], + $continue ? [ 'exptime >= ' . $db->addQuotes( $continue ) ] : [] + ), + __METHOD__, + [ 'LIMIT' => min( $limit, 100 ), 'ORDER BY' => 'exptime' ] + ); + + if ( $res->numRows() ) { + $row = $res->current(); + if ( $lag === null ) { + $lag = max( $cutoffUnix - wfTimestamp( TS_UNIX, $row->exptime ), 1 ); + } + + $keys = []; + foreach ( $res as $row ) { + $keys[] = $row->keyname; + $continue = $row->exptime; + } + + $db->delete( + $this->getTableNameByShard( $shardIndex ), + [ + 'exptime < ' . $db->addQuotes( $db->timestamp( $cutoffUnix ) ), + 'keyname' => $keys + ], + __METHOD__ + ); + $keysDeletedCount += $db->affectedRows(); + } + + if ( is_callable( $progressCallback ) ) { + if ( $lag ) { + $remainingLag = $cutoffUnix - wfTimestamp( TS_UNIX, $continue ); + $processedLag = max( $lag - $remainingLag, 0 ); + $doneRatio = ( $numShardsDone + $processedLag / $lag ) / $this->shards; + } else { + $doneRatio = 1; + } + + $overallRatio = ( $doneRatio / $this->numServers ) + + ( $serversDoneCount / $this->numServers ); + call_user_func( $progressCallback, $overallRatio * 100 ); + } + } while ( $res->numRows() && $keysDeletedCount < $limit ); + } } /** @@ -709,9 +746,10 @@ class SqlBagOStuff extends BagOStuff { * @return bool */ public function deleteAll() { + /** @noinspection PhpUnusedLocalVariableInspection */ $silenceScope = $this->silenceTransactionProfiler(); for ( $serverIndex = 0; $serverIndex < $this->numServers; $serverIndex++ ) { - $db = null; + $db = null; // in case of connection failure try { $db = $this->getDB( $serverIndex ); for ( $i = 0; $i < $this->shards; $i++ ) { @@ -725,22 +763,91 @@ class SqlBagOStuff extends BagOStuff { return true; } + public function lock( $key, $timeout = 6, $expiry = 6, $rclass = '' ) { + // Avoid deadlocks and allow lock reentry if specified + if ( isset( $this->locks[$key] ) ) { + if ( $rclass != '' && $this->locks[$key]['class'] === $rclass ) { + ++$this->locks[$key]['depth']; + return true; + } else { + return false; + } + } + + list( $serverIndex ) = $this->getTableByKey( $key ); + + $db = null; // in case of connection failure + try { + $db = $this->getDB( $serverIndex ); + $ok = $db->lock( $key, __METHOD__, $timeout ); + if ( $ok ) { + $this->locks[$key] = [ 'class' => $rclass, 'depth' => 1 ]; + } + + $this->logger->warning( + __METHOD__ . " failed due to timeout for {key}.", + [ 'key' => $key, 'timeout' => $timeout ] + ); + + return $ok; + } catch ( DBError $e ) { + $this->handleWriteError( $e, $db, $serverIndex ); + $ok = false; + } + + return $ok; + } + + public function unlock( $key ) { + if ( !isset( $this->locks[$key] ) ) { + return false; + } + + if ( --$this->locks[$key]['depth'] <= 0 ) { + unset( $this->locks[$key] ); + + list( $serverIndex ) = $this->getTableByKey( $key ); + + $db = null; // in case of connection failure + try { + $db = $this->getDB( $serverIndex ); + $ok = $db->unlock( $key, __METHOD__ ); + if ( !$ok ) { + $this->logger->warning( + __METHOD__ . ' failed to release lock for {key}.', + [ 'key' => $key ] + ); + } + } catch ( DBError $e ) { + $this->handleWriteError( $e, $db, $serverIndex ); + $ok = false; + } + + return $ok; + } + + return true; + } + /** * Serialize an object and, if possible, compress the representation. * On typical message and page data, this can provide a 3X decrease * in storage requirements. * - * @param mixed &$data - * @return string + * @param mixed $data + * @return string|int */ - protected function serialize( &$data ) { - $serial = serialize( $data ); + protected function serialize( $data ) { + if ( is_int( $data ) ) { + return $data; + } + $serial = serialize( $data ); if ( function_exists( 'gzdeflate' ) ) { - return gzdeflate( $serial ); - } else { - return $serial; + $serial = gzdeflate( $serial ); } + + return $serial; } /** @@ -749,6 +856,10 @@ class SqlBagOStuff extends BagOStuff { * @return mixed */ protected function unserialize( $serial ) { + if ( $this->isInteger( $serial ) ) { + return (int)$serial; + } + if ( function_exists( 'gzinflate' ) ) { Wikimedia\suppressWarnings(); $decomp = gzinflate( $serial ); @@ -759,9 +870,7 @@ class SqlBagOStuff extends BagOStuff { } } - $ret = unserialize( $serial ); - - return $ret; + return unserialize( $serial ); } /** @@ -786,8 +895,8 @@ class SqlBagOStuff extends BagOStuff { * @param int $serverIndex * @throws Exception */ - protected function handleWriteError( DBError $exception, IDatabase $db = null, $serverIndex ) { - if ( !$db ) { + protected function handleWriteError( DBError $exception, $db, $serverIndex ) { + if ( !( $db instanceof IDatabase ) ) { $this->markServerDown( $exception, $serverIndex ); } diff --git a/includes/page/Article.php b/includes/page/Article.php index aa38d1f787..f158e4df0b 100644 --- a/includes/page/Article.php +++ b/includes/page/Article.php @@ -454,7 +454,9 @@ class Article implements Page { $this->mRevIdFetched = $this->mRevision->getId(); $this->fetchResult = Status::newGood( $this->mRevision ); - if ( !$this->mRevision->userCan( Revision::DELETED_TEXT, $this->getContext()->getUser() ) ) { + if ( + !$this->mRevision->userCan( RevisionRecord::DELETED_TEXT, $this->getContext()->getUser() ) + ) { wfDebug( __METHOD__ . " failed to retrieve content of revision " . $this->mRevision->getId() . "\n" ); @@ -466,7 +468,7 @@ class Article implements Page { if ( Hooks::isRegistered( 'ArticleAfterFetchContentObject' ) ) { $contentObject = $this->mRevision->getContent( - Revision::FOR_THIS_USER, + RevisionRecord::FOR_THIS_USER, $this->getContext()->getUser() ); @@ -489,7 +491,7 @@ class Article implements Page { // For B/C only $this->mContentObject = $this->mRevision->getContent( - Revision::FOR_THIS_USER, + RevisionRecord::FOR_THIS_USER, $this->getContext()->getUser() ); @@ -1400,11 +1402,11 @@ class Article implements Page { # Show delete and move logs if there were any such events. # The logging query can DOS the site when bots/crawlers cause 404 floods, # so be careful showing this. 404 pages must be cheap as they are hard to cache. - $cache = $services->getMainObjectStash(); - $key = $cache->makeKey( 'page-recent-delete', md5( $title->getPrefixedText() ) ); + $dbCache = ObjectCache::getInstance( 'db-replicated' ); + $key = $dbCache->makeKey( 'page-recent-delete', md5( $title->getPrefixedText() ) ); $loggedIn = $this->getContext()->getUser()->isLoggedIn(); $sessionExists = $this->getContext()->getRequest()->getSession()->isPersistent(); - if ( $loggedIn || $cache->get( $key ) || $sessionExists ) { + if ( $loggedIn || $dbCache->get( $key ) || $sessionExists ) { $logTypes = [ 'delete', 'move', 'protect' ]; $dbr = wfGetDB( DB_REPLICA ); @@ -1481,7 +1483,7 @@ class Article implements Page { * @return bool True if the view is allowed, false if not. */ public function showDeletedRevisionHeader() { - if ( !$this->mRevision->isDeleted( Revision::DELETED_TEXT ) ) { + if ( !$this->mRevision->isDeleted( RevisionRecord::DELETED_TEXT ) ) { // Not deleted return true; } @@ -1489,7 +1491,7 @@ class Article implements Page { $outputPage = $this->getContext()->getOutput(); $user = $this->getContext()->getUser(); // If the user is not allowed to see it... - if ( !$this->mRevision->userCan( Revision::DELETED_TEXT, $user ) ) { + if ( !$this->mRevision->userCan( RevisionRecord::DELETED_TEXT, $user ) ) { $outputPage->wrapWikiMsg( "\n", 'rev-deleted-text-permission' ); @@ -1499,7 +1501,7 @@ class Article implements Page { # Give explanation and add a link to view the revision... $oldid = intval( $this->getOldID() ); $link = $this->getTitle()->getFullURL( "oldid={$oldid}&unhide=1" ); - $msg = $this->mRevision->isDeleted( Revision::DELETED_RESTRICTED ) ? + $msg = $this->mRevision->isDeleted( RevisionRecord::DELETED_RESTRICTED ) ? 'rev-suppressed-text-unhide' : 'rev-deleted-text-unhide'; $outputPage->wrapWikiMsg( "\n", [ $msg, $link ] ); @@ -1507,7 +1509,7 @@ class Article implements Page { return false; // We are allowed to see... } else { - $msg = $this->mRevision->isDeleted( Revision::DELETED_RESTRICTED ) ? + $msg = $this->mRevision->isDeleted( RevisionRecord::DELETED_RESTRICTED ) ? 'rev-suppressed-text-view' : 'rev-deleted-text-view'; $outputPage->wrapWikiMsg( "\n", $msg ); @@ -2412,7 +2414,7 @@ class Article implements Page { * Call to WikiPage function for backwards compatibility. * @see WikiPage::getComment */ - public function getComment( $audience = Revision::FOR_PUBLIC, User $user = null ) { + public function getComment( $audience = RevisionRecord::FOR_PUBLIC, User $user = null ) { return $this->mPage->getComment( $audience, $user ); } @@ -2444,7 +2446,7 @@ class Article implements Page { * Call to WikiPage function for backwards compatibility. * @see WikiPage::getCreator */ - public function getCreator( $audience = Revision::FOR_PUBLIC, User $user = null ) { + public function getCreator( $audience = RevisionRecord::FOR_PUBLIC, User $user = null ) { return $this->mPage->getCreator( $audience, $user ); } @@ -2556,7 +2558,7 @@ class Article implements Page { * Call to WikiPage function for backwards compatibility. * @see WikiPage::getUser */ - public function getUser( $audience = Revision::FOR_PUBLIC, User $user = null ) { + public function getUser( $audience = RevisionRecord::FOR_PUBLIC, User $user = null ) { return $this->mPage->getUser( $audience, $user ); } @@ -2564,7 +2566,7 @@ class Article implements Page { * Call to WikiPage function for backwards compatibility. * @see WikiPage::getUserText */ - public function getUserText( $audience = Revision::FOR_PUBLIC, User $user = null ) { + public function getUserText( $audience = RevisionRecord::FOR_PUBLIC, User $user = null ) { return $this->mPage->getUserText( $audience, $user ); } diff --git a/includes/page/ImagePage.php b/includes/page/ImagePage.php index e929ed8a73..16b83d1f50 100644 --- a/includes/page/ImagePage.php +++ b/includes/page/ImagePage.php @@ -29,7 +29,7 @@ use Wikimedia\Rdbms\ResultWrapper; * @ingroup Media */ class ImagePage extends Article { - /** @var File */ + /** @var File|false */ private $displayImg; /** @var FileRepo */ @@ -801,7 +801,7 @@ EOT } /** - * @param string $target + * @param string|string[] $target * @param int $limit * @return ResultWrapper */ @@ -935,7 +935,7 @@ EOT ) . "\n" ); - }; + } $out->addHTML( Html::closeElement( 'ul' ) . "\n" ); $res->free(); diff --git a/includes/page/PageArchive.php b/includes/page/PageArchive.php index cdaf06268b..d69a433d9c 100644 --- a/includes/page/PageArchive.php +++ b/includes/page/PageArchive.php @@ -406,8 +406,8 @@ class PageArchive { * @param User|null $user User performing the action, or null to use $wgUser * @param string|string[]|null $tags Change tags to add to log entry * ($user should be able to add the specified tags before this is called) - * @return array|bool array(number of file revisions restored, number of image revisions - * restored, log message) on success, false on failure. + * @return array|bool [ number of file revisions restored, number of image revisions + * restored, log message ] on success, false on failure. */ public function undelete( $timestamps, $comment = '', $fileVersions = [], $unsuppress = false, User $user = null, $tags = null diff --git a/includes/page/WikiFilePage.php b/includes/page/WikiFilePage.php index 8df9ab242a..acd506ba79 100644 --- a/includes/page/WikiFilePage.php +++ b/includes/page/WikiFilePage.php @@ -29,13 +29,13 @@ use Wikimedia\Rdbms\FakeResultWrapper; * @ingroup Media */ class WikiFilePage extends WikiPage { - /** @var File */ + /** @var File|false */ protected $mFile = false; - /** @var LocalRepo */ + /** @var LocalRepo|null */ protected $mRepo = null; /** @var bool */ protected $mFileLoaded = false; - /** @var array */ + /** @var array|null */ protected $mDupes = null; public function __construct( $title ) { @@ -152,7 +152,7 @@ class WikiFilePage extends WikiPage { $size = $this->mFile->getSize(); /** - * @var $file File + * @var File $file */ foreach ( $dupes as $index => $file ) { $key = $file->getRepoName() . ':' . $file->getName(); diff --git a/includes/page/WikiPage.php b/includes/page/WikiPage.php index 332b1ee482..173fdc6e97 100644 --- a/includes/page/WikiPage.php +++ b/includes/page/WikiPage.php @@ -813,7 +813,7 @@ class WikiPage implements Page, IDBAccessObject { * * @since 1.21 */ - public function getContent( $audience = Revision::FOR_PUBLIC, User $user = null ) { + public function getContent( $audience = RevisionRecord::FOR_PUBLIC, User $user = null ) { $this->loadLastEdit(); if ( $this->mLastRevision ) { return $this->mLastRevision->getContent( $audience, $user ); @@ -851,7 +851,7 @@ class WikiPage implements Page, IDBAccessObject { * to the $audience parameter * @return int User ID for the user that made the last article revision */ - public function getUser( $audience = Revision::FOR_PUBLIC, User $user = null ) { + public function getUser( $audience = RevisionRecord::FOR_PUBLIC, User $user = null ) { $this->loadLastEdit(); if ( $this->mLastRevision ) { return $this->mLastRevision->getUser( $audience, $user ); @@ -870,7 +870,7 @@ class WikiPage implements Page, IDBAccessObject { * to the $audience parameter * @return User|null */ - public function getCreator( $audience = Revision::FOR_PUBLIC, User $user = null ) { + public function getCreator( $audience = RevisionRecord::FOR_PUBLIC, User $user = null ) { $revision = $this->getOldestRevision(); if ( $revision ) { $userName = $revision->getUserText( $audience, $user ); @@ -889,7 +889,7 @@ class WikiPage implements Page, IDBAccessObject { * to the $audience parameter * @return string Username of the user that made the last article revision */ - public function getUserText( $audience = Revision::FOR_PUBLIC, User $user = null ) { + public function getUserText( $audience = RevisionRecord::FOR_PUBLIC, User $user = null ) { $this->loadLastEdit(); if ( $this->mLastRevision ) { return $this->mLastRevision->getUserText( $audience, $user ); @@ -908,7 +908,7 @@ class WikiPage implements Page, IDBAccessObject { * @return string|null Comment stored for the last article revision, or null if the specified * audience does not have access to the comment. */ - public function getComment( $audience = Revision::FOR_PUBLIC, User $user = null ) { + public function getComment( $audience = RevisionRecord::FOR_PUBLIC, User $user = null ) { $this->loadLastEdit(); if ( $this->mLastRevision ) { return $this->mLastRevision->getComment( $audience, $user ); @@ -1176,7 +1176,7 @@ class WikiPage implements Page, IDBAccessObject { $conds[] = 'NOT(' . $actorMigration->getWhere( $dbr, 'rev_user', $user )['conds'] . ')'; // Username hidden? - $conds[] = "{$dbr->bitAnd( 'rev_deleted', Revision::DELETED_USER )} = 0"; + $conds[] = "{$dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_USER )} = 0"; $jconds = [ 'user' => [ 'LEFT JOIN', $actorQuery['fields']['rev_user'] . ' = user_id' ], @@ -1588,7 +1588,7 @@ class WikiPage implements Page, IDBAccessObject { $baseRevId = null; if ( $edittime && $sectionId !== 'new' ) { $lb = $this->getDBLoadBalancer(); - $dbr = $lb->getConnection( DB_REPLICA ); + $dbr = $lb->getConnectionRef( DB_REPLICA ); $rev = Revision::loadFromTimestamp( $dbr, $this->mTitle, $edittime ); // Try the master if this thread may have just added it. // This could be abstracted into a Revision method, but we don't want @@ -1597,7 +1597,7 @@ class WikiPage implements Page, IDBAccessObject { && $lb->getServerCount() > 1 && $lb->hasOrMadeRecentMasterChanges() ) { - $dbw = $lb->getConnection( DB_MASTER ); + $dbw = $lb->getConnectionRef( DB_MASTER ); $rev = Revision::loadFromTimestamp( $dbw, $this->mTitle, $edittime ); } if ( $rev ) { @@ -1697,6 +1697,7 @@ class WikiPage implements Page, IDBAccessObject { MediaWikiServices::getInstance()->getDBLoadBalancerFactory() ); + $derivedDataUpdater->setLogger( LoggerFactory::getInstance( 'SaveParse' ) ); $derivedDataUpdater->setRcWatchCategoryMembership( $wgRCWatchCategoryMembership ); $derivedDataUpdater->setArticleCountMethod( $wgArticleCountMethod ); @@ -1904,7 +1905,11 @@ class WikiPage implements Page, IDBAccessObject { // TODO: this logic should not be in the storage layer, it's here for compatibility // with 1.31 behavior. Applying the 'autopatrol' right should be done in the same // place the 'bot' right is handled, which is currently in EditPage::attemptSave. - if ( $needsPatrol && $this->getTitle()->userCan( 'autopatrol', $user ) ) { + $permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); + + if ( $needsPatrol && $permissionManager->userCan( + 'autopatrol', $user, $this->getTitle() + ) ) { $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED ); } @@ -2106,6 +2111,11 @@ class WikiPage implements Page, IDBAccessObject { * - defer: one of the DeferredUpdates constants, or false to run immediately (default: false). * Note that even when this is set to false, some updates might still get deferred (as * some update might directly add child updates to DeferredUpdates). + * - known-revision-output: a combined canonical ParserOutput for the revision, perhaps + * from some cache. The caller is responsible for ensuring that the ParserOutput indeed + * matched the $rev and $options. This mechanism is intended as a temporary stop-gap, + * for the time until caches have been changed to store RenderedRevision states instead + * of ParserOutput objects. (default: null) (since 1.33) * @since 1.32 */ public function doSecondaryDataUpdates( array $options = [] ) { @@ -2685,7 +2695,7 @@ class WikiPage implements Page, IDBAccessObject { // we need to remember the old content so we can use it to generate all deletion updates. $revision = $this->getRevision(); try { - $content = $this->getContent( Revision::RAW ); + $content = $this->getContent( RevisionRecord::RAW ); } catch ( Exception $ex ) { wfLogWarning( __METHOD__ . ': failed to load content during deletion! ' . $ex->getMessage() ); @@ -2801,9 +2811,9 @@ class WikiPage implements Page, IDBAccessObject { $status->value = $logid; // Show log excerpt on 404 pages rather than just a link - $cache = MediaWikiServices::getInstance()->getMainObjectStash(); - $key = $cache->makeKey( 'page-recent-delete', md5( $logTitle->getPrefixedText() ) ); - $cache->set( $key, 1, $cache::TTL_DAY ); + $dbCache = ObjectCache::getInstance( 'db-replicated' ); + $key = $dbCache->makeKey( 'page-recent-delete', md5( $logTitle->getPrefixedText() ) ); + $dbCache->set( $key, 1, $dbCache::TTL_DAY ); } return $status; @@ -2834,7 +2844,7 @@ class WikiPage implements Page, IDBAccessObject { // Bitfields to further suppress the content if ( $suppress ) { - $bitfield = Revision::SUPPRESSED_ALL; + $bitfield = RevisionRecord::SUPPRESSED_ALL; $revQuery['fields'] = array_diff( $revQuery['fields'], [ 'rev_deleted' ] ); } @@ -3073,7 +3083,7 @@ class WikiPage implements Page, IDBAccessObject { * (with ChangeTags::canAddTagsAccompanyingChange) * * @return array Array of errors, each error formatted as - * array(messagekey, param1, param2, ...). + * [ messagekey, param1, param2, ... ]. * On success, the array is empty. This array can also be passed to * OutputPage::showPermissionsErrorPage(). */ @@ -3267,7 +3277,11 @@ class WikiPage implements Page, IDBAccessObject { // TODO: this logic should not be in the storage layer, it's here for compatibility // with 1.31 behavior. Applying the 'autopatrol' right should be done in the same // place the 'bot' right is handled, which is currently in EditPage::attemptSave. - if ( $wgUseRCPatrol && $this->getTitle()->userCan( 'autopatrol', $guser ) ) { + $permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); + + if ( $wgUseRCPatrol && $permissionManager->userCan( + 'autopatrol', $guser, $this->getTitle() + ) ) { $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED ); } @@ -3754,7 +3768,7 @@ class WikiPage implements Page, IDBAccessObject { $slotContent = [ SlotRecord::MAIN => $rev ]; } else { $slotContent = array_map( function ( SlotRecord $slot ) { - return $slot->getContent( Revision::RAW ); + return $slot->getContent( RevisionRecord::RAW ); }, $rev->getSlots()->getSlots() ); } diff --git a/includes/parser/CoreParserFunctions.php b/includes/parser/CoreParserFunctions.php index 7fece00398..5aa1a691b0 100644 --- a/includes/parser/CoreParserFunctions.php +++ b/includes/parser/CoreParserFunctions.php @@ -823,7 +823,7 @@ class CoreParserFunctions { } // fetch revision from cache/database and return the value - $rev = self::getCachedRevisionObject( $parser, $title ); + $rev = self::getCachedRevisionObject( $parser, $title, 'vary-revision-sha1' ); $length = $rev ? $rev->getSize() : 0; if ( $length === null ) { // We've had bugs where rev_len was not being recorded for empty pages, see T135414 @@ -1126,41 +1126,56 @@ class CoreParserFunctions { * * @param Parser $parser * @param Title $title + * @param string $vary ParserOuput vary-* flag * @return Revision * @since 1.23 */ - private static function getCachedRevisionObject( $parser, $title = null ) { - if ( is_null( $title ) ) { + private static function getCachedRevisionObject( $parser, $title, $vary ) { + if ( !$title ) { return null; } - // Use the revision from the parser itself, when param is the current page - // and the revision is the current one - if ( $title->equals( $parser->getTitle() ) ) { - $parserRev = $parser->getRevisionObject(); - if ( $parserRev && $parserRev->isCurrent() ) { - // force reparse after edit with vary-revision flag - $parser->getOutput()->setFlag( 'vary-revision' ); - wfDebug( __METHOD__ . ": use current revision from parser, setting vary-revision...\n" ); - return $parserRev; + $revision = null; + + $isSelfReferential = $title->equals( $parser->getTitle() ); + if ( $isSelfReferential ) { + // Revision is for the same title that is currently being parsed. Only use the last + // saved revision, regardless of Parser::getRevisionId() or fake revision injection + // callbacks against the current title. + $parserRevision = $parser->getRevisionObject(); + if ( $parserRevision && $parserRevision->isCurrent() ) { + $revision = $parserRevision; + wfDebug( __METHOD__ . ": used current revision, setting $vary" ); } } - // Normalize name for cache - $page = $title->getPrefixedDBkey(); - - if ( !( $parser->currentRevisionCache && $parser->currentRevisionCache->has( $page ) ) - && !$parser->incrementExpensiveFunctionCount() ) { - return null; + $parserOutput = $parser->getOutput(); + if ( !$revision ) { + if ( + !$parser->isCurrentRevisionOfTitleCached( $title ) && + !$parser->incrementExpensiveFunctionCount() + ) { + return null; // not allowed + } + // Get the current revision, ignoring Parser::getRevisionId() being null/old + $revision = $parser->fetchCurrentRevisionOfTitle( $title ); + // Register dependency in templatelinks + $parserOutput->addTemplate( + $title, + $revision ? $revision->getPage() : 0, + $revision ? $revision->getId() : 0 + ); } - $rev = $parser->fetchCurrentRevisionOfTitle( $title ); - $pageID = $rev ? $rev->getPage() : 0; - $revID = $rev ? $rev->getId() : 0; - // Register dependency in templatelinks - $parser->getOutput()->addTemplate( $title, $pageID, $revID ); + if ( $isSelfReferential ) { + // Upon page save, the result of the parser function using this might change + $parserOutput->setFlag( $vary ); + if ( $vary === 'vary-revision-sha1' && $revision ) { + $parserOutput->setRevisionUsedSha1Base36( $revision->getSha1() ); + } + } - return $rev; + return $revision; } /** @@ -1221,7 +1236,7 @@ class CoreParserFunctions { return ''; } // fetch revision from cache/database and return the value - $rev = self::getCachedRevisionObject( $parser, $t ); + $rev = self::getCachedRevisionObject( $parser, $t, 'vary-revision-id' ); return $rev ? $rev->getId() : ''; } @@ -1238,7 +1253,7 @@ class CoreParserFunctions { return ''; } // fetch revision from cache/database and return the value - $rev = self::getCachedRevisionObject( $parser, $t ); + $rev = self::getCachedRevisionObject( $parser, $t, 'vary-revision-timestamp' ); return $rev ? MWTimestamp::getLocalInstance( $rev->getTimestamp() )->format( 'j' ) : ''; } @@ -1255,7 +1270,7 @@ class CoreParserFunctions { return ''; } // fetch revision from cache/database and return the value - $rev = self::getCachedRevisionObject( $parser, $t ); + $rev = self::getCachedRevisionObject( $parser, $t, 'vary-revision-timestamp' ); return $rev ? MWTimestamp::getLocalInstance( $rev->getTimestamp() )->format( 'd' ) : ''; } @@ -1272,7 +1287,7 @@ class CoreParserFunctions { return ''; } // fetch revision from cache/database and return the value - $rev = self::getCachedRevisionObject( $parser, $t ); + $rev = self::getCachedRevisionObject( $parser, $t, 'vary-revision-timestamp' ); return $rev ? MWTimestamp::getLocalInstance( $rev->getTimestamp() )->format( 'm' ) : ''; } @@ -1289,7 +1304,7 @@ class CoreParserFunctions { return ''; } // fetch revision from cache/database and return the value - $rev = self::getCachedRevisionObject( $parser, $t ); + $rev = self::getCachedRevisionObject( $parser, $t, 'vary-revision-timestamp' ); return $rev ? MWTimestamp::getLocalInstance( $rev->getTimestamp() )->format( 'n' ) : ''; } @@ -1306,7 +1321,7 @@ class CoreParserFunctions { return ''; } // fetch revision from cache/database and return the value - $rev = self::getCachedRevisionObject( $parser, $t ); + $rev = self::getCachedRevisionObject( $parser, $t, 'vary-revision-timestamp' ); return $rev ? MWTimestamp::getLocalInstance( $rev->getTimestamp() )->format( 'Y' ) : ''; } @@ -1323,7 +1338,7 @@ class CoreParserFunctions { return ''; } // fetch revision from cache/database and return the value - $rev = self::getCachedRevisionObject( $parser, $t ); + $rev = self::getCachedRevisionObject( $parser, $t, 'vary-revision-timestamp' ); return $rev ? MWTimestamp::getLocalInstance( $rev->getTimestamp() )->format( 'YmdHis' ) : ''; } @@ -1340,7 +1355,7 @@ class CoreParserFunctions { return ''; } // fetch revision from cache/database and return the value - $rev = self::getCachedRevisionObject( $parser, $t ); + $rev = self::getCachedRevisionObject( $parser, $t, 'vary-user' ); return $rev ? $rev->getUserText() : ''; } diff --git a/includes/parser/PPCustomFrame_DOM.php b/includes/parser/PPCustomFrame_DOM.php index 70663a0dea..d274558afc 100644 --- a/includes/parser/PPCustomFrame_DOM.php +++ b/includes/parser/PPCustomFrame_DOM.php @@ -21,6 +21,7 @@ /** * Expansion frame with custom arguments + * @deprecated since 1.34, use PPCustomFrame_Hash * @ingroup Parser */ // phpcs:ignore Squiz.Classes.ValidClassName.NotCamelCaps diff --git a/includes/parser/PPDStack.php b/includes/parser/PPDStack.php index 4108bd7e13..adc0bc0014 100644 --- a/includes/parser/PPDStack.php +++ b/includes/parser/PPDStack.php @@ -27,7 +27,7 @@ class PPDStack { public $stack, $rootAccum; /** - * @var PPDStack + * @var PPDStack|false */ public $top; public $out; diff --git a/includes/parser/PPFrame_DOM.php b/includes/parser/PPFrame_DOM.php index a7fea0028a..452bab1259 100644 --- a/includes/parser/PPFrame_DOM.php +++ b/includes/parser/PPFrame_DOM.php @@ -21,6 +21,7 @@ /** * An expansion frame, used as a context to expand the result of preprocessToObj() + * @deprecated since 1.34, use PPFrame_Hash * @ingroup Parser */ // phpcs:ignore Squiz.Classes.ValidClassName.NotCamelCaps @@ -140,7 +141,7 @@ class PPFrame_DOM implements PPFrame { /** * @throws MWException * @param string|int $key - * @param string|PPNode_DOM|DOMDocument $root + * @param string|PPNode_DOM|DOMNode|DOMNodeList $root * @param int $flags * @return string */ @@ -151,7 +152,7 @@ class PPFrame_DOM implements PPFrame { /** * @throws MWException - * @param string|PPNode_DOM|DOMDocument $root + * @param string|PPNode_DOM|DOMNode $root * @param int $flags * @return string */ @@ -395,7 +396,7 @@ class PPFrame_DOM implements PPFrame { /** * @param string $sep * @param int $flags - * @param string|PPNode_DOM|DOMDocument ...$args + * @param string|PPNode_DOM|DOMNode ...$args * @return string */ public function implodeWithFlags( $sep, $flags, ...$args ) { @@ -425,7 +426,7 @@ class PPFrame_DOM implements PPFrame { * This previously called implodeWithFlags but has now been inlined to reduce stack depth * * @param string $sep - * @param string|PPNode_DOM|DOMDocument ...$args + * @param string|PPNode_DOM|DOMNode ...$args * @return string */ public function implode( $sep, ...$args ) { @@ -455,7 +456,7 @@ class PPFrame_DOM implements PPFrame { * with implode() * * @param string $sep - * @param string|PPNode_DOM|DOMDocument ...$args + * @param string|PPNode_DOM|DOMNode ...$args * @return array */ public function virtualImplode( $sep, ...$args ) { @@ -486,7 +487,7 @@ class PPFrame_DOM implements PPFrame { * @param string $start * @param string $sep * @param string $end - * @param string|PPNode_DOM|DOMDocument ...$args + * @param string|PPNode_DOM|DOMNode ...$args * @return array */ public function virtualBracketedImplode( $start, $sep, $end, ...$args ) { diff --git a/includes/parser/PPNode.php b/includes/parser/PPNode.php index 2b6cf7c341..4561657e2f 100644 --- a/includes/parser/PPNode.php +++ b/includes/parser/PPNode.php @@ -36,20 +36,20 @@ interface PPNode { /** * Get an array-type node containing the children of this node. * Returns false if this is not a tree node. - * @return PPNode + * @return false|PPNode */ public function getChildren(); /** * Get the first child of a tree node. False if there isn't one. * - * @return PPNode + * @return false|PPNode */ public function getFirstChild(); /** * Get the next sibling of any node. False if there isn't one - * @return PPNode + * @return false|PPNode */ public function getNextSibling(); @@ -57,7 +57,7 @@ interface PPNode { * Get all children of this tree node which have a given name. * Returns an array-type node, or false if this is not a tree node. * @param string $type - * @return bool|PPNode + * @return false|PPNode */ public function getChildrenOfType( $type ); diff --git a/includes/parser/PPNode_DOM.php b/includes/parser/PPNode_DOM.php index 8a435bab67..53b17617bb 100644 --- a/includes/parser/PPNode_DOM.php +++ b/includes/parser/PPNode_DOM.php @@ -20,13 +20,14 @@ */ /** + * @deprecated since 1.34, use PPNode_Hash_{Tree,Text,Array,Attr} * @ingroup Parser */ // phpcs:ignore Squiz.Classes.ValidClassName.NotCamelCaps class PPNode_DOM implements PPNode { /** - * @var DOMElement + * @var DOMElement|DOMNodeList */ public $node; public $xpath; @@ -58,21 +59,21 @@ class PPNode_DOM implements PPNode { } /** - * @return bool|PPNode_DOM + * @return false|PPNode_DOM */ public function getChildren() { return $this->node->childNodes ? new self( $this->node->childNodes ) : false; } /** - * @return bool|PPNode_DOM + * @return false|PPNode_DOM */ public function getFirstChild() { return $this->node->firstChild ? new self( $this->node->firstChild ) : false; } /** - * @return bool|PPNode_DOM + * @return false|PPNode_DOM */ public function getNextSibling() { return $this->node->nextSibling ? new self( $this->node->nextSibling ) : false; @@ -81,7 +82,7 @@ class PPNode_DOM implements PPNode { /** * @param string $type * - * @return bool|PPNode_DOM + * @return false|PPNode_DOM */ public function getChildrenOfType( $type ) { return new self( $this->getXPath()->query( $type, $this->node ) ); diff --git a/includes/parser/PPNode_Hash_Tree.php b/includes/parser/PPNode_Hash_Tree.php index e6cabf8e51..7782894125 100644 --- a/includes/parser/PPNode_Hash_Tree.php +++ b/includes/parser/PPNode_Hash_Tree.php @@ -135,7 +135,7 @@ class PPNode_Hash_Tree implements PPNode { * return a temporary proxy object: different instances will be returned * if this is called more than once on the same node. * - * @return PPNode_Hash_Tree|PPNode_Hash_Attr|PPNode_Hash_Text|bool + * @return PPNode_Hash_Tree|PPNode_Hash_Attr|PPNode_Hash_Text|false */ public function getFirstChild() { if ( !isset( $this->rawChildren[0] ) ) { @@ -150,7 +150,7 @@ class PPNode_Hash_Tree implements PPNode { * return a temporary proxy object: different instances will be returned * if this is called more than once on the same node. * - * @return PPNode_Hash_Tree|PPNode_Hash_Attr|PPNode_Hash_Text|bool + * @return PPNode_Hash_Tree|PPNode_Hash_Attr|PPNode_Hash_Text|false */ public function getNextSibling() { return self::factory( $this->store, $this->index + 1 ); diff --git a/includes/parser/PPTemplateFrame_DOM.php b/includes/parser/PPTemplateFrame_DOM.php index 52cb9cb0f0..b4c874371d 100644 --- a/includes/parser/PPTemplateFrame_DOM.php +++ b/includes/parser/PPTemplateFrame_DOM.php @@ -21,6 +21,7 @@ /** * Expansion frame with template arguments + * @deprecated since 1.34, use PPTemplateFrame_Hash * @ingroup Parser */ // phpcs:ignore Squiz.Classes.ValidClassName.NotCamelCaps diff --git a/includes/parser/Parser.php b/includes/parser/Parser.php index 486fdf4413..6600f8f72b 100644 --- a/includes/parser/Parser.php +++ b/includes/parser/Parser.php @@ -26,7 +26,9 @@ use MediaWiki\Linker\LinkRendererFactory; use MediaWiki\Linker\LinkTarget; use MediaWiki\MediaWikiServices; use MediaWiki\Special\SpecialPageFactory; +use Psr\Log\NullLogger; use Wikimedia\ScopedCallback; +use Psr\Log\LoggerInterface; /** * @defgroup Parser Parser @@ -205,9 +207,11 @@ class Parser { public $mLinkID; public $mIncludeSizes, $mPPNodeCount, $mGeneratedPPNodeCount, $mHighestExpansionDepth; public $mDefaultSort; - public $mTplRedirCache, $mTplDomCache, $mHeadings, $mDoubleUnderscores; + public $mTplRedirCache, $mHeadings, $mDoubleUnderscores; public $mExpensiveFunctionCount; # number of expensive parser function calls public $mShowToc, $mForceTocPosition; + /** @var array */ + public $mTplDomCache; /** * @var User @@ -292,6 +296,9 @@ class Parser { /** @var NamespaceInfo */ private $nsInfo; + /** @var LoggerInterface */ + private $logger; + /** * TODO Make this a const when HHVM support is dropped (T192166) * @@ -331,11 +338,18 @@ class Parser { * @param SpecialPageFactory|null $spFactory * @param LinkRendererFactory|null $linkRendererFactory * @param NamespaceInfo|null $nsInfo + * @param LoggerInterface|null $logger */ public function __construct( - $svcOptions = null, MagicWordFactory $magicWordFactory = null, - Language $contLang = null, ParserFactory $factory = null, $urlProtocols = null, - SpecialPageFactory $spFactory = null, $linkRendererFactory = null, $nsInfo = null + $svcOptions = null, + MagicWordFactory $magicWordFactory = null, + Language $contLang = null, + ParserFactory $factory = null, + $urlProtocols = null, + SpecialPageFactory $spFactory = null, + $linkRendererFactory = null, + $nsInfo = null, + $logger = null ) { $services = MediaWikiServices::getInstance(); if ( !$svcOptions || is_array( $svcOptions ) ) { @@ -380,6 +394,7 @@ class Parser { $this->specialPageFactory = $spFactory ?? $services->getSpecialPageFactory(); $this->linkRendererFactory = $linkRendererFactory ?? $services->getLinkRendererFactory(); $this->nsInfo = $nsInfo ?? $services->getNamespaceInfo(); + $this->logger = $logger ?: new NullLogger(); } /** @@ -421,21 +436,10 @@ class Parser { * Which class should we use for the preprocessor if not otherwise specified? * * @since 1.34 + * @deprecated since 1.34, removing configurability of preprocessor * @return string */ public static function getDefaultPreprocessorClass() { - if ( wfIsHHVM() ) { - # Under HHVM Preprocessor_Hash is much faster than Preprocessor_DOM - return Preprocessor_Hash::class; - } - if ( extension_loaded( 'domxml' ) ) { - # PECL extension that conflicts with the core DOM extension (T15770) - wfDebug( "Warning: you have the obsolete domxml extension for PHP. Please remove it!\n" ); - return Preprocessor_Hash::class; - } - if ( extension_loaded( 'dom' ) ) { - return Preprocessor_DOM::class; - } return Preprocessor_Hash::class; } @@ -899,7 +903,7 @@ class Parser { /** * Accessor for the Title object * - * @return Title + * @return Title|null */ public function getTitle() { return $this->mTitle; @@ -2785,8 +2789,7 @@ class Parser { # which means the user is previewing a new page. # The vary-revision flag must be set, because the magic word # will have a different value once the page is saved. - $this->mOutput->setFlag( 'vary-revision' ); - wfDebug( __METHOD__ . ": {{PAGEID}} used in a new page, setting vary-revision...\n" ); + $this->setOutputFlag( 'vary-revision', '{{PAGEID}} on new page' ); } $value = $pageid ?: null; break; @@ -2802,14 +2805,13 @@ class Parser { if ( $this->getRevisionId() || $this->mOptions->getSpeculativeRevId() ) { $value = '-'; } else { - $this->mOutput->setFlag( 'vary-revision-exists' ); + $this->setOutputFlag( 'vary-revision-exists', '{{REVISIONID}} used' ); $value = ''; } } else { # Inform the edit saving system that getting the canonical output after # revision insertion requires another parse using the actual revision ID - $this->mOutput->setFlag( 'vary-revision-id' ); - wfDebug( __METHOD__ . ": {{REVISIONID}} used, setting vary-revision-id...\n" ); + $this->setOutputFlag( 'vary-revision-id', '{{REVISIONID}} used' ); $value = $this->getRevisionId(); if ( $value === 0 ) { $rev = $this->getRevisionObject(); @@ -2839,17 +2841,12 @@ class Parser { $value = $this->getRevisionTimestampSubstring( 0, 4, self::MAX_TTS, $index ); break; case 'revisiontimestamp': - # Let the edit saving system know we should parse the page - # *after* a revision ID has been assigned. This is for null edits. - $this->mOutput->setFlag( 'vary-revision' ); - wfDebug( __METHOD__ . ": {{REVISIONTIMESTAMP}} used, setting vary-revision...\n" ); - $value = $this->getRevisionTimestamp(); + $value = $this->getRevisionTimestampSubstring( 0, 14, self::MAX_TTS, $index ); break; case 'revisionuser': - # Let the edit saving system know we should parse the page - # *after* a revision ID has been assigned for null edits. - $this->mOutput->setFlag( 'vary-user' ); - wfDebug( __METHOD__ . ": {{REVISIONUSER}} used, setting vary-user...\n" ); + # Inform the edit saving system that getting the canonical output after + # revision insertion requires a parse that used the actual user ID + $this->setOutputFlag( 'vary-user', '{{REVISIONUSER}} used' ); $value = $this->getRevisionUser(); break; case 'revisionsize': @@ -2997,7 +2994,7 @@ class Parser { /** * @param int $start * @param int $len - * @param int $mtts Max time-till-save; sets vary-revision if result might change by then + * @param int $mtts Max time-till-save; sets vary-revision-timestamp if result changes by then * @param string $variable Parser variable name * @return string */ @@ -3006,7 +3003,10 @@ class Parser { $resNow = substr( $this->getRevisionTimestamp(), $start, $len ); # Possibly set vary-revision if there is not yet an associated revision if ( !$this->getRevisionObject() ) { - # Get the timezone-adjusted timestamp $mtts seconds in the future + # Get the timezone-adjusted timestamp $mtts seconds in the future. + # This future is relative to the current time and not that of the + # parser options. The rendered timestamp can be compared to that + # of the timestamp specified by the parser options. $resThen = substr( $this->contLang->userAdjust( wfTimestamp( TS_MW, time() + $mtts ), '' ), $start, @@ -3014,10 +3014,9 @@ class Parser { ); if ( $resNow !== $resThen ) { - # Let the edit saving system know we should parse the page - # *after* a revision ID has been assigned. This is for null edits. - $this->mOutput->setFlag( 'vary-revision' ); - wfDebug( __METHOD__ . ": $variable used, setting vary-revision...\n" ); + # Inform the edit saving system that getting the canonical output after + # revision insertion requires a parse that used an actual revision timestamp + $this->setOutputFlag( 'vary-revision-timestamp', "$variable used" ); } } @@ -3095,7 +3094,7 @@ class Parser { * self::OT_HTML: all templates and extension tags * * @param string $text The text to transform - * @param bool|PPFrame $frame Object describing the arguments passed to the + * @param false|PPFrame|array $frame Object describing the arguments passed to the * template. Arguments may also be provided as an associative array, as * was the usual case before MW1.12. Providing arguments this way may be * useful for extensions wishing to perform variable replacement @@ -3114,8 +3113,10 @@ class Parser { if ( $frame === false ) { $frame = $this->getPreprocessor()->newFrame(); } elseif ( !( $frame instanceof PPFrame ) ) { - wfDebug( __METHOD__ . " called using plain parameters instead of " - . "a PPFrame instance. Creating custom frame.\n" ); + $this->logger->debug( + __METHOD__ . " called using plain parameters instead of " . + "a PPFrame instance. Creating custom frame." + ); $frame = $this->getPreprocessor()->newCustomFrame( $frame ); } @@ -3201,7 +3202,7 @@ class Parser { * $piece['lineStart']: whether the brace was at the start of a line * @param PPFrame $frame The current frame, contains template arguments * @throws Exception - * @return string The text of the template + * @return string|array The text of the template */ public function braceSubstitution( $piece, $frame ) { // Flags @@ -3416,8 +3417,10 @@ class Parser { } } elseif ( $this->nsInfo->isNonincludable( $title->getNamespace() ) ) { $found = false; # access denied - wfDebug( __METHOD__ . ": template inclusion denied for " . - $title->getPrefixedDBkey() . "\n" ); + $this->logger->debug( + __METHOD__ . + ": template inclusion denied for " . $title->getPrefixedDBkey() + ); } else { list( $text, $title ) = $this->getTemplateDom( $title ); if ( $text !== false ) { @@ -3455,7 +3458,7 @@ class Parser { $this->addTrackingCategory( 'template-loop-category' ); $this->mOutput->addWarning( wfMessage( 'template-loop-warning', wfEscapeWikiText( $titleText ) )->text() ); - wfDebug( __METHOD__ . ": template loop broken at '$titleText'\n" ); + $this->logger->debug( __METHOD__ . ": template loop broken at '$titleText'" ); } } @@ -3702,6 +3705,18 @@ class Parser { return $this->currentRevisionCache->get( $cacheKey ); } + /** + * @param Title $title + * @return bool + * @since 1.34 + */ + public function isCurrentRevisionOfTitleCached( $title ) { + return ( + $this->currentRevisionCache && + $this->currentRevisionCache->has( $title->getPrefixedText() ) + ); + } + /** * Wrapper around Revision::newFromTitle to allow passing additional parameters * without passing them on to it. @@ -3736,9 +3751,8 @@ class Parser { foreach ( $stuff['deps'] as $dep ) { $this->mOutput->addTemplate( $dep['title'], $dep['page_id'], $dep['rev_id'] ); if ( $dep['title']->equals( $this->getTitle() ) ) { - // If we transclude ourselves, the final result - // will change based on the new version of the page - $this->mOutput->setFlag( 'vary-revision' ); + // Self-transclusion; final result may change based on the new page version + $this->setOutputFlag( 'vary-revision', 'Self transclusion' ); } } } @@ -3847,19 +3861,6 @@ class Parser { 'deps' => $deps ]; } - /** - * Fetch a file and its title and register a reference to it. - * If 'broken' is a key in $options then the file will appear as a broken thumbnail. - * @param Title $title - * @param array $options Array of options to RepoGroup::findFile - * @return File|bool - * @deprecated since 1.32, use fetchFileAndTitle instead - */ - public function fetchFile( $title, $options = [] ) { - wfDeprecated( __METHOD__, '1.32' ); - return $this->fetchFileAndTitle( $title, $options )[0]; - } - /** * Fetch a file and its title and register a reference to it. * If 'broken' is a key in $options then the file will appear as a broken thumbnail. @@ -4695,7 +4696,7 @@ class Parser { '~~~' => $sigText ] ); # The main two signature forms used above are time-sensitive - $this->mOutput->setFlag( 'user-signature' ); + $this->setOutputFlag( 'user-signature', 'User signature detected' ); } # Context links ("pipe tricks"): [[|name]] and [[name (context)|]] @@ -4760,7 +4761,7 @@ class Parser { if ( mb_strlen( $nickname ) > $this->svcOptions->get( 'MaxSigChars' ) ) { $nickname = $username; - wfDebug( __METHOD__ . ": $username has overlong signature.\n" ); + $this->logger->debug( __METHOD__ . ": $username has overlong signature." ); } elseif ( $fancySig !== false ) { # Sig. might contain markup; validate this if ( $this->validateSig( $nickname ) !== false ) { @@ -4769,7 +4770,7 @@ class Parser { } else { # Failed to validate; fall back to the default $nickname = $username; - wfDebug( __METHOD__ . ": $username has bad XML tags in signature.\n" ); + $this->logger->debug( __METHOD__ . ": $username has bad XML tags in signature." ); } } @@ -5266,7 +5267,8 @@ class Parser { $handlerOptions[$paramName] = $match; } else { // Guess not, consider it as caption. - wfDebug( "$parameterMatch failed parameter validation\n" ); + $this->logger->debug( + "$parameterMatch failed parameter validation" ); $label = $parameterMatch; } } @@ -5652,7 +5654,7 @@ class Parser { * @deprecated since 1.28; use getOutput()->updateCacheExpiry() */ public function disableCache() { - wfDebug( "Parser output marked as uncacheable.\n" ); + $this->logger->debug( "Parser output marked as uncacheable." ); if ( !$this->mOutput ) { throw new MWException( __METHOD__ . " can only be called when actually parsing something" ); @@ -5916,7 +5918,7 @@ class Parser { * * The return value will be either: * - a) Positive, indicating a specific revision ID (current or old) - * - b) Zero, meaning the revision ID specified by getCurrentRevisionCallback() + * - b) Zero, meaning the revision ID is specified by getCurrentRevisionCallback() * - c) Null, meaning the parse is for preview mode and there is no revision * * @return int|null @@ -5971,20 +5973,25 @@ class Parser { /** * Get the timestamp associated with the current revision, adjusted for * the default server-local timestamp - * @return string + * @return string TS_MW timestamp */ public function getRevisionTimestamp() { - if ( is_null( $this->mRevisionTimestamp ) ) { - $revObject = $this->getRevisionObject(); - $timestamp = $revObject ? $revObject->getTimestamp() : wfTimestampNow(); - - # The cryptic '' timezone parameter tells to use the site-default - # timezone offset instead of the user settings. - # Since this value will be saved into the parser cache, served - # to other users, and potentially even used inside links and such, - # it needs to be consistent for all visitors. - $this->mRevisionTimestamp = $this->contLang->userAdjust( $timestamp, '' ); + if ( $this->mRevisionTimestamp !== null ) { + return $this->mRevisionTimestamp; } + + # Use specified revision timestamp, falling back to the current timestamp + $revObject = $this->getRevisionObject(); + $timestamp = $revObject ? $revObject->getTimestamp() : $this->mOptions->getTimestamp(); + $this->mOutput->setRevisionTimestampUsed( $timestamp ); // unadjusted time zone + + # The cryptic '' timezone parameter tells to use the site-default + # timezone offset instead of the user settings. + # Since this value will be saved into the parser cache, served + # to other users, and potentially even used inside links and such, + # it needs to be consistent for all visitors. + $this->mRevisionTimestamp = $this->contLang->userAdjust( $timestamp, '' ); + return $this->mRevisionTimestamp; } @@ -6453,4 +6460,14 @@ class Parser { OutputPage::setupOOUI(); $this->mOutput->setEnableOOUI( true ); } + + /** + * @param string $flag + * @param string $reason + */ + protected function setOutputFlag( $flag, $reason ) { + $this->mOutput->setFlag( $flag ); + $name = $this->mTitle->getPrefixedText(); + $this->logger->debug( __METHOD__ . ": set $flag flag on '$name'; $reason" ); + } } diff --git a/includes/parser/ParserFactory.php b/includes/parser/ParserFactory.php index 0446d9c640..3d15e86fd5 100644 --- a/includes/parser/ParserFactory.php +++ b/includes/parser/ParserFactory.php @@ -23,6 +23,8 @@ use MediaWiki\Config\ServiceOptions; use MediaWiki\Linker\LinkRendererFactory; use MediaWiki\MediaWikiServices; use MediaWiki\Special\SpecialPageFactory; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; /** * @since 1.32 @@ -49,6 +51,9 @@ class ParserFactory { /** @var NamespaceInfo */ private $nsInfo; + /** @var LoggerInterface */ + private $logger; + /** * Old parameter list, which we support for backwards compatibility, were: * array $parserConf See $wgParserConf documentation @@ -71,12 +76,18 @@ class ParserFactory { * @param SpecialPageFactory $spFactory * @param LinkRendererFactory $linkRendererFactory * @param NamespaceInfo|LinkRendererFactory|null $nsInfo + * @param LoggerInterface|null $logger * @since 1.32 */ public function __construct( - $svcOptions, MagicWordFactory $magicWordFactory, Language $contLang, - $urlProtocols, SpecialPageFactory $spFactory, $linkRendererFactory, - $nsInfo = null + $svcOptions, + MagicWordFactory $magicWordFactory, + Language $contLang, + $urlProtocols, + SpecialPageFactory $spFactory, + $linkRendererFactory, + $nsInfo = null, + $logger = null ) { // @todo Do we need to retain compat for constructing this class directly? if ( !$nsInfo ) { @@ -107,6 +118,7 @@ class ParserFactory { $this->specialPageFactory = $spFactory; $this->linkRendererFactory = $linkRendererFactory; $this->nsInfo = $nsInfo; + $this->logger = $logger ?: new NullLogger(); } /** @@ -114,8 +126,16 @@ class ParserFactory { * @since 1.32 */ public function create() : Parser { - return new Parser( $this->svcOptions, $this->magicWordFactory, $this->contLang, $this, - $this->urlProtocols, $this->specialPageFactory, $this->linkRendererFactory, - $this->nsInfo ); + return new Parser( + $this->svcOptions, + $this->magicWordFactory, + $this->contLang, + $this, + $this->urlProtocols, + $this->specialPageFactory, + $this->linkRendererFactory, + $this->nsInfo, + $this->logger + ); } } diff --git a/includes/parser/ParserOptions.php b/includes/parser/ParserOptions.php index 66b1612245..709f159bb0 100644 --- a/includes/parser/ParserOptions.php +++ b/includes/parser/ParserOptions.php @@ -895,7 +895,7 @@ class ParserOptions { /** * Timestamp used for {{CURRENTDAY}} etc. - * @return string + * @return string TS_MW timestamp */ public function getTimestamp() { if ( !isset( $this->mTimestamp ) ) { @@ -913,27 +913,6 @@ class ParserOptions { return wfSetVar( $this->mTimestamp, $x ); } - /** - * Create "edit section" links? - * @deprecated since 1.31, use ParserOutput::getText() options instead. - * @return bool - */ - public function getEditSection() { - wfDeprecated( __METHOD__, '1.31' ); - return true; - } - - /** - * Create "edit section" links? - * @deprecated since 1.31, use ParserOutput::getText() options instead. - * @param bool|null $x New value (null is no change) - * @return bool Old value - */ - public function setEditSection( $x ) { - wfDeprecated( __METHOD__, '1.31' ); - return true; - } - /** * Set the redirect target. * diff --git a/includes/parser/ParserOutput.php b/includes/parser/ParserOutput.php index ab7348f25b..23e5911574 100644 --- a/includes/parser/ParserOutput.php +++ b/includes/parser/ParserOutput.php @@ -26,8 +26,14 @@ class ParserOutput extends CacheTime { /** * Feature flags to indicate to extensions that MediaWiki core supports and * uses getText() stateless transforms. + * + * @since 1.31 */ const SUPPORTS_STATELESS_TRANSFORMS = 1; + + /** + * @since 1.31 + */ const SUPPORTS_UNWRAP_TRANSFORM = 1; /** @@ -207,6 +213,12 @@ class ParserOutput extends CacheTime { /** @var int|null Assumed rev ID for {{REVISIONID}} if no revision is set */ private $mSpeculativeRevId; + /** @var int|null Assumed rev timestamp for {{REVISIONTIMESTAMP}} if no revision is set */ + private $revisionTimestampUsed; + + /** @var string|null SHA-1 base 36 hash of any self-transclusion */ + private $revisionUsedSha1Base36; + /** string CSS classes to use for the wrapping div, stored in the array keys. * If no class is given, no wrapper is added. */ @@ -439,6 +451,49 @@ class ParserOutput extends CacheTime { return $this->mSpeculativeRevId; } + /** + * @param string $timestamp TS_MW timestamp + * @since 1.34 + */ + public function setRevisionTimestampUsed( $timestamp ) { + $this->revisionTimestampUsed = $timestamp; + } + + /** + * @return string|null TS_MW timestamp or null if not used + * @since 1.34 + */ + public function getRevisionTimestampUsed() { + return $this->revisionTimestampUsed; + } + + /** + * @param string $hash Lowercase SHA-1 base 36 hash + * @since 1.34 + */ + public function setRevisionUsedSha1Base36( $hash ) { + if ( $hash === null ) { + return; // e.g. RevisionRecord::getSha1() returned null + } + + if ( + $this->revisionUsedSha1Base36 !== null && + $this->revisionUsedSha1Base36 !== $hash + ) { + $this->revisionUsedSha1Base36 = ''; // mismatched + } else { + $this->revisionUsedSha1Base36 = $hash; + } + } + + /** + * @return string|null Lowercase SHA-1 base 36 hash, null if unused, or "" on inconsistency + * @since 1.34 + */ + public function getRevisionUsedSha1Base36() { + return $this->revisionUsedSha1Base36; + } + public function &getLanguageLinks() { return $this->mLanguageLinks; } diff --git a/includes/parser/Preprocessor_DOM.php b/includes/parser/Preprocessor_DOM.php index 0f0496beac..fb8a1dc544 100644 --- a/includes/parser/Preprocessor_DOM.php +++ b/includes/parser/Preprocessor_DOM.php @@ -19,6 +19,7 @@ * * @file * @ingroup Parser + * @deprecated since 1.34, use Preprocessor_Hash */ /** @@ -36,7 +37,11 @@ class Preprocessor_DOM extends Preprocessor { const CACHE_PREFIX = 'preprocess-xml'; + /** + * @param Parser $parser + */ public function __construct( $parser ) { + wfDeprecated( __METHOD__, '1.34' ); // T204945 $this->parser = $parser; $mem = ini_get( 'memory_limit' ); $this->memoryLimit = false; diff --git a/includes/parser/Preprocessor_Hash.php b/includes/parser/Preprocessor_Hash.php index 66f081fe77..f7f37ac044 100644 --- a/includes/parser/Preprocessor_Hash.php +++ b/includes/parser/Preprocessor_Hash.php @@ -50,6 +50,9 @@ class Preprocessor_Hash extends Preprocessor { const CACHE_PREFIX = 'preprocess-hash'; const CACHE_VERSION = 2; + /** + * @param Parser $parser + */ public function __construct( $parser ) { $this->parser = $parser; } diff --git a/includes/parser/Sanitizer.php b/includes/parser/Sanitizer.php index f76e3a9c5d..8e0cf5c877 100644 --- a/includes/parser/Sanitizer.php +++ b/includes/parser/Sanitizer.php @@ -790,7 +790,7 @@ class Sanitizer { */ static function validateTagAttributes( $attribs, $element ) { return self::validateAttributes( $attribs, - self::attributeWhitelist( $element ) ); + self::attributeWhitelistInternal( $element ) ); } /** @@ -802,14 +802,21 @@ class Sanitizer { * - Invalid id attributes are re-encoded * * @param array $attribs - * @param array $whitelist List of allowed attribute names + * @param array $whitelist List of allowed attribute names, + * either as a sequential array of valid attribute names or + * as an associative array where keys give valid attribute names * @return array * * @todo Check for legal values where the DTD limits things. * @todo Check for unique id attribute :P */ static function validateAttributes( $attribs, $whitelist ) { - $whitelist = array_flip( $whitelist ); + if ( isset( $whitelist[0] ) ) { + // We would like to eventually deprecate calling this + // function with a sequential array, but for now just + // convert it. + $whitelist = array_flip( $whitelist ); + } $hrefExp = '/^(' . wfUrlProtocols() . ')[^\s]+$/'; $out = []; @@ -828,10 +835,10 @@ class Sanitizer { # * Disallow data attributes used by MediaWiki code # * Ensure that the attribute is not namespaced by banning # colons. - if ( !preg_match( '/^data-[^:]*$/i', $attribute ) - && !isset( $whitelist[$attribute] ) - || self::isReservedDataAttribute( $attribute ) - ) { + if ( ( + !preg_match( '/^data-[^:]*$/i', $attribute ) && + !array_key_exists( $attribute, $whitelist ) + ) || self::isReservedDataAttribute( $attribute ) ) { continue; } @@ -1245,7 +1252,7 @@ class Sanitizer { * HTML5 definition of id attribute * * @param string $id Id to escape - * @param string|array $options String or array of strings (default is array()): + * @param string|array $options String or array of strings (default is []): * 'noninitial': This is a non-initial fragment of an id, not a full id, * so don't pay attention if the first character isn't valid at the * beginning of an id. @@ -1746,26 +1753,63 @@ class Sanitizer { * Fetch the whitelist of acceptable attributes for a given element name. * * @param string $element - * @return array + * @return array A sequential array of acceptable attribute names + * @deprecated since 1.34; should be private */ static function attributeWhitelist( $element ) { + wfDeprecated( __METHOD__, '1.34' ); $list = self::setupAttributeWhitelist(); return $list[$element] ?? []; } + /** + * Fetch the whitelist of acceptable attributes for a given element name. + * + * @param string $element + * @return array An associative array where keys are acceptable attribute + * names + */ + private static function attributeWhitelistInternal( $element ) { + $list = self::setupAttributeWhitelistInternal(); + return $list[$element] ?? []; + } + /** * Foreach array key (an allowed HTML element), return an array * of allowed attributes * @return array + * @deprecated since 1.34; should be private */ static function setupAttributeWhitelist() { + wfDeprecated( __METHOD__, '1.34' ); + $wlist = self::setupAttributeWhitelistInternal(); + // This method is expected to return a sequential array as the + // value for each HTML element key. + return array_map( function ( $v ) { + return array_keys( $v ); + }, $wlist ); + } + + /** + * Foreach array key (an allowed HTML element), return an array + * of allowed attributes + * @return array An associative array: keys are HTML element names; + * values are associative arrays where the keys are allowed attribute + * names. + */ + private static function setupAttributeWhitelistInternal() { static $whitelist; if ( $whitelist !== null ) { return $whitelist; } - $common = [ + // For lookup efficiency flip each attributes array so the keys are + // the valid attributes. + $merge = function ( $a, $b, $c = [] ) { + return array_merge( $a, array_flip( $b ), array_flip( $c ) ); + }; + $common = $merge( [], [ # HTML 'id', 'class', @@ -1798,9 +1842,10 @@ class Sanitizer { 'itemref', 'itemscope', 'itemtype', - ]; + ] ); + + $block = $merge( $common, [ 'align' ] ); - $block = array_merge( $common, [ 'align' ] ); $tablealign = [ 'align', 'valign' ]; $tablecell = [ 'abbr', @@ -1850,8 +1895,8 @@ class Sanitizer { # acronym # 9.2.2 - 'blockquote' => array_merge( $common, [ 'cite' ] ), - 'q' => array_merge( $common, [ 'cite' ] ), + 'blockquote' => $merge( $common, [ 'cite' ] ), + 'q' => $merge( $common, [ 'cite' ] ), # 9.2.3 'sub' => $common, @@ -1861,22 +1906,22 @@ class Sanitizer { 'p' => $block, # 9.3.2 - 'br' => array_merge( $common, [ 'clear' ] ), + 'br' => $merge( $common, [ 'clear' ] ), # https://www.w3.org/TR/html5/text-level-semantics.html#the-wbr-element 'wbr' => $common, # 9.3.4 - 'pre' => array_merge( $common, [ 'width' ] ), + 'pre' => $merge( $common, [ 'width' ] ), # 9.4 - 'ins' => array_merge( $common, [ 'cite', 'datetime' ] ), - 'del' => array_merge( $common, [ 'cite', 'datetime' ] ), + 'ins' => $merge( $common, [ 'cite', 'datetime' ] ), + 'del' => $merge( $common, [ 'cite', 'datetime' ] ), # 10.2 - 'ul' => array_merge( $common, [ 'type' ] ), - 'ol' => array_merge( $common, [ 'type', 'start', 'reversed' ] ), - 'li' => array_merge( $common, [ 'type', 'value' ] ), + 'ul' => $merge( $common, [ 'type' ] ), + 'ol' => $merge( $common, [ 'type', 'start', 'reversed' ] ), + 'li' => $merge( $common, [ 'type', 'value' ] ), # 10.3 'dl' => $common, @@ -1884,7 +1929,7 @@ class Sanitizer { 'dt' => $common, # 11.2.1 - 'table' => array_merge( $common, + 'table' => $merge( $common, [ 'summary', 'width', 'border', 'frame', 'rules', 'cellspacing', 'cellpadding', 'align', 'bgcolor', @@ -1899,31 +1944,31 @@ class Sanitizer { 'tbody' => $common, # 11.2.4 - 'colgroup' => array_merge( $common, [ 'span' ] ), - 'col' => array_merge( $common, [ 'span' ] ), + 'colgroup' => $merge( $common, [ 'span' ] ), + 'col' => $merge( $common, [ 'span' ] ), # 11.2.5 - 'tr' => array_merge( $common, [ 'bgcolor' ], $tablealign ), + 'tr' => $merge( $common, [ 'bgcolor' ], $tablealign ), # 11.2.6 - 'td' => array_merge( $common, $tablecell, $tablealign ), - 'th' => array_merge( $common, $tablecell, $tablealign ), + 'td' => $merge( $common, $tablecell, $tablealign ), + 'th' => $merge( $common, $tablecell, $tablealign ), # 12.2 # NOTE: is not allowed directly, but the attrib # whitelist is used from the Parser object - 'a' => array_merge( $common, [ 'href', 'rel', 'rev' ] ), # rel/rev esp. for RDFa + 'a' => $merge( $common, [ 'href', 'rel', 'rev' ] ), # rel/rev esp. for RDFa # 13.2 # Not usually allowed, but may be used for extension-style hooks # such as when it is rasterized, or if $wgAllowImageTag is # true - 'img' => array_merge( $common, [ 'alt', 'src', 'width', 'height', 'srcset' ] ), + 'img' => $merge( $common, [ 'alt', 'src', 'width', 'height', 'srcset' ] ), # Attributes for A/V tags added in T163583 / T133673 - 'audio' => array_merge( $common, [ 'controls', 'preload', 'width', 'height' ] ), - 'video' => array_merge( $common, [ 'poster', 'controls', 'preload', 'width', 'height' ] ), - 'source' => array_merge( $common, [ 'type', 'src' ] ), - 'track' => array_merge( $common, [ 'type', 'src', 'srclang', 'kind', 'label' ] ), + 'audio' => $merge( $common, [ 'controls', 'preload', 'width', 'height' ] ), + 'video' => $merge( $common, [ 'poster', 'controls', 'preload', 'width', 'height' ] ), + 'source' => $merge( $common, [ 'type', 'src' ] ), + 'track' => $merge( $common, [ 'type', 'src', 'srclang', 'kind', 'label' ] ), # 15.2.1 'tt' => $common, @@ -1936,11 +1981,11 @@ class Sanitizer { 'u' => $common, # 15.2.2 - 'font' => array_merge( $common, [ 'size', 'color', 'face' ] ), + 'font' => $merge( $common, [ 'size', 'color', 'face' ] ), # basefont # 15.3 - 'hr' => array_merge( $common, [ 'width' ] ), + 'hr' => $merge( $common, [ 'width' ] ), # HTML Ruby annotation text module, simple ruby only. # https://www.w3.org/TR/html5/text-level-semantics.html#the-ruby-element @@ -1948,13 +1993,13 @@ class Sanitizer { # rbc 'rb' => $common, 'rp' => $common, - 'rt' => $common, # array_merge( $common, array( 'rbspan' ) ), + 'rt' => $common, # $merge( $common, [ 'rbspan' ] ), 'rtc' => $common, # MathML root element, where used for extensions # 'title' may not be 100% valid here; it's XHTML # https://www.w3.org/TR/REC-MathML/ - 'math' => [ 'class', 'style', 'id', 'title' ], + 'math' => $merge( [], [ 'class', 'style', 'id', 'title' ] ), // HTML 5 section 4.5 'figure' => $common, @@ -1966,8 +2011,8 @@ class Sanitizer { # HTML5 elements, defined by: # https://html.spec.whatwg.org/multipage/semantics.html#the-data-element - 'data' => array_merge( $common, [ 'value' ] ), - 'time' => array_merge( $common, [ 'datetime' ] ), + 'data' => $merge( $common, [ 'value' ] ), + 'time' => $merge( $common, [ 'datetime' ] ), 'mark' => $common, // meta and link are only permitted by removeHTMLtags when Microdata @@ -1975,8 +2020,8 @@ class Sanitizer { // Also meta and link are only valid in WikiText as Microdata elements // (ie: validateTag rejects tags missing the attributes needed for Microdata) // So we don't bother including $common attributes that have no purpose. - 'meta' => [ 'itemprop', 'content' ], - 'link' => [ 'itemprop', 'href', 'title' ], + 'meta' => $merge( [], [ 'itemprop', 'content' ] ), + 'link' => $merge( [], [ 'itemprop', 'href', 'title' ] ), ]; return $whitelist; diff --git a/includes/password/LayeredParameterizedPassword.php b/includes/password/LayeredParameterizedPassword.php index 841305481a..f3d8d03e30 100644 --- a/includes/password/LayeredParameterizedPassword.php +++ b/includes/password/LayeredParameterizedPassword.php @@ -109,7 +109,7 @@ class LayeredParameterizedPassword extends ParameterizedPassword { foreach ( $this->config['types'] as $i => $type ) { if ( $i == 0 ) { continue; - }; + } // Construct pseudo-hash based on params and arguments /** @var ParameterizedPassword $passObj */ diff --git a/includes/poolcounter/PoolCounterWork.php b/includes/poolcounter/PoolCounterWork.php index 16e439797f..737967959f 100644 --- a/includes/poolcounter/PoolCounterWork.php +++ b/includes/poolcounter/PoolCounterWork.php @@ -29,6 +29,8 @@ abstract class PoolCounterWork { protected $type = 'generic'; /** @var bool */ protected $cacheable = false; // does this override getCachedWork() ? + /** @var PoolCounter */ + private $poolCounter; /** * @param string $type The class of actions to limit concurrency for (task type) diff --git a/includes/rcfeed/RedisPubSubFeedEngine.php b/includes/rcfeed/RedisPubSubFeedEngine.php index c954df1a5b..66b1529986 100644 --- a/includes/rcfeed/RedisPubSubFeedEngine.php +++ b/includes/rcfeed/RedisPubSubFeedEngine.php @@ -29,10 +29,10 @@ * * @par Example: * @code - * $wgRCFeeds['redis'] = array( + * $wgRCFeeds['redis'] = [ * 'formatter' => 'JSONRCFeedFormatter', * 'uri' => "redis://127.0.0.1:6379/rc.$wgDBname", - * ); + * ]; * @endcode * * @since 1.22 diff --git a/includes/registration/ExtensionProcessor.php b/includes/registration/ExtensionProcessor.php index faaaece456..6182d5fdc4 100644 --- a/includes/registration/ExtensionProcessor.php +++ b/includes/registration/ExtensionProcessor.php @@ -65,6 +65,7 @@ class ExtensionProcessor implements Processor { protected static $coreAttributes = [ 'SkinOOUIThemes', 'TrackingCategories', + 'RestRoutes', ]; /** @@ -119,6 +120,7 @@ class ExtensionProcessor implements Processor { 'ResourceFileModulePaths', 'ResourceModules', 'ResourceModuleSkinStyles', + 'OOUIThemePaths', 'QUnitTestModule', 'ExtensionMessagesFiles', 'MessagesDirs', @@ -444,7 +446,7 @@ class ExtensionProcessor implements Processor { } } - foreach ( [ 'ResourceModules', 'ResourceModuleSkinStyles' ] as $setting ) { + foreach ( [ 'ResourceModules', 'ResourceModuleSkinStyles', 'OOUIThemePaths' ] as $setting ) { if ( isset( $info[$setting] ) ) { foreach ( $info[$setting] as $name => $data ) { if ( isset( $data['localBasePath'] ) ) { @@ -458,7 +460,11 @@ class ExtensionProcessor implements Processor { if ( $defaultPaths ) { $data += $defaultPaths; } - $this->globals["wg$setting"][$name] = $data; + if ( $setting === 'OOUIThemePaths' ) { + $this->attributes[$setting][$name] = $data; + } else { + $this->globals["wg$setting"][$name] = $data; + } } } } diff --git a/includes/registration/ExtensionRegistry.php b/includes/registration/ExtensionRegistry.php index 768b488323..9cae73c907 100644 --- a/includes/registration/ExtensionRegistry.php +++ b/includes/registration/ExtensionRegistry.php @@ -300,6 +300,13 @@ class ExtensionRegistry { } $dir = dirname( $path ); + self::exportAutoloadClassesAndNamespaces( + $dir, + $info, + $autoloadClasses, + $autoloadNamespaces + ); + if ( isset( $info['AutoloadClasses'] ) ) { $autoload = $this->processAutoLoader( $dir, $info['AutoloadClasses'] ); $GLOBALS['wgAutoloadClasses'] += $autoload; @@ -347,6 +354,28 @@ class ExtensionRegistry { return $data; } + /** + * Export autoload classes and namespaces for a given directory and parsed JSON info file. + * + * @param string $dir + * @param array $info + * @param array &$autoloadClasses + * @param array &$autoloadNamespaces + */ + public static function exportAutoloadClassesAndNamespaces( + $dir, $info, &$autoloadClasses = [], &$autoloadNamespaces = [] + ) { + if ( isset( $info['AutoloadClasses'] ) ) { + $autoload = self::processAutoLoader( $dir, $info['AutoloadClasses'] ); + $GLOBALS['wgAutoloadClasses'] += $autoload; + $autoloadClasses += $autoload; + } + if ( isset( $info['AutoloadNamespaces'] ) ) { + $autoloadNamespaces += self::processAutoLoader( $dir, $info['AutoloadNamespaces'] ); + AutoLoader::$psr4Namespaces += $autoloadNamespaces; + } + } + protected function exportExtractedData( array $info ) { foreach ( $info['globals'] as $key => $val ) { // If a merge strategy is set, read it and remove it from the value @@ -511,7 +540,7 @@ class ExtensionRegistry { * @param array $files * @return array */ - protected function processAutoLoader( $dir, array $files ) { + protected static function processAutoLoader( $dir, array $files ) { // Make paths absolute, relative to the JSON file foreach ( $files as &$file ) { $file = "$dir/$file"; diff --git a/includes/resourceloader/MessageBlobStore.php b/includes/resourceloader/MessageBlobStore.php index 74d0616d1d..fca06c9160 100644 --- a/includes/resourceloader/MessageBlobStore.php +++ b/includes/resourceloader/MessageBlobStore.php @@ -1,7 +1,5 @@ touchCheckKey( $cache->makeGlobalKey( __CLASS__ ), $cache::HOLDOFF_NONE ); + $cache->touchCheckKey( $cache->makeGlobalKey( __CLASS__ ), $cache::HOLDOFF_TTL_NONE ); } /** diff --git a/includes/resourceloader/ResourceLoader.php b/includes/resourceloader/ResourceLoader.php index 515f287a6c..671fe86c7c 100644 --- a/includes/resourceloader/ResourceLoader.php +++ b/includes/resourceloader/ResourceLoader.php @@ -118,11 +118,10 @@ class ResourceLoader implements LoggerAwareInterface { return; } $dbr = wfGetDB( DB_REPLICA ); - $skin = $context->getSkin(); $lang = $context->getLanguage(); // Batched version of ResourceLoaderModule::getFileDependencies - $vary = "$skin|$lang"; + $vary = ResourceLoaderModule::getVary( $context ); $res = $dbr->select( 'module_deps', [ 'md_module', 'md_deps' ], [ 'md_module' => $moduleNames, 'md_skin' => $vary, @@ -196,8 +195,7 @@ class ResourceLoader implements LoggerAwareInterface { $cache = ObjectCache::getLocalServerInstance( CACHE_ANYTHING ); $key = $cache->makeGlobalKey( - 'resourceloader', - 'filter', + 'resourceloader-filter', $filter, self::CACHE_VERSION, md5( $data ) @@ -299,13 +297,13 @@ class ResourceLoader implements LoggerAwareInterface { /** * Register a module with the ResourceLoader system. * - * @param mixed $name Name of module as a string or List of name/object pairs as an array - * @param array|null $info Module info array. For backwards compatibility with 1.17alpha, - * this may also be a ResourceLoaderModule object. Optional when using - * multiple-registration calling style. + * @param string|array[] $name Module name as a string or, array of module info arrays + * keyed by name. + * @param array|null $info Module info array. When using the first parameter to register + * multiple modules at once, this parameter is optional. * @throws MWException If a duplicate module registration is attempted * @throws MWException If a module name contains illegal characters (pipes or commas) - * @throws MWException If something other than a ResourceLoaderModule is being registered + * @throws InvalidArgumentException If the module info is not an array */ public function register( $name, $info = null ) { $moduleSkinStyles = $this->config->get( 'ResourceModuleSkinStyles' ); @@ -322,29 +320,21 @@ class ResourceLoader implements LoggerAwareInterface { ); } - // Check $name for validity + // Check validity if ( !self::isValidModuleName( $name ) ) { throw new MWException( "ResourceLoader module name '$name' is invalid, " . "see ResourceLoader::isValidModuleName()" ); } - - // Attach module - if ( $info instanceof ResourceLoaderModule ) { - $this->moduleInfos[$name] = [ 'object' => $info ]; - $info->setName( $name ); - $this->modules[$name] = $info; - } elseif ( is_array( $info ) ) { - // New calling convention - $this->moduleInfos[$name] = $info; - } else { - throw new MWException( - 'ResourceLoader module info type error for module \'' . $name . - '\': expected ResourceLoaderModule or array (got: ' . gettype( $info ) . ')' + if ( !is_array( $info ) ) { + throw new InvalidArgumentException( + 'Invalid module info for "' . $name . '": expected array, got ' . gettype( $info ) ); } - // Last-minute changes + // Attach module + $this->moduleInfos[$name] = $info; + // Last-minute changes // Apply custom skin-defined styles to existing modules. if ( $this->isFileModule( $name ) ) { foreach ( $moduleSkinStyles as $skinName => $skinStyles ) { @@ -530,23 +520,18 @@ class ResourceLoader implements LoggerAwareInterface { // No such module return null; } - // Construct the requested object + // Construct the requested module object $info = $this->moduleInfos[$name]; - /** @var ResourceLoaderModule $object */ - if ( isset( $info['object'] ) ) { - // Object given in info array - $object = $info['object']; - } elseif ( isset( $info['factory'] ) ) { + if ( isset( $info['factory'] ) ) { + /** @var ResourceLoaderModule $object */ $object = call_user_func( $info['factory'], $info ); - $object->setConfig( $this->getConfig() ); - $object->setLogger( $this->logger ); } else { $class = $info['class'] ?? ResourceLoaderFileModule::class; /** @var ResourceLoaderModule $object */ $object = new $class( $info ); - $object->setConfig( $this->getConfig() ); - $object->setLogger( $this->logger ); } + $object->setConfig( $this->getConfig() ); + $object->setLogger( $this->logger ); $object->setName( $name ); $this->modules[$name] = $object; } @@ -565,10 +550,7 @@ class ResourceLoader implements LoggerAwareInterface { return false; } $info = $this->moduleInfos[$name]; - if ( isset( $info['object'] ) ) { - return false; - } - return ( + return !isset( $info['factory'] ) && ( // The implied default for 'class' is ResourceLoaderFileModule !isset( $info['class'] ) || // Explicit default @@ -1049,9 +1031,6 @@ MESSAGE; $states[$name] = 'missing'; } - // Generate output - $isRaw = false; - $filter = $context->getOnly() === 'styles' ? 'minify-css' : 'minify-js'; foreach ( $modules as $name => $module ) { @@ -1130,12 +1109,11 @@ MESSAGE; $states[$name] = 'error'; unset( $modules[$name] ); } - $isRaw |= $module->isRaw(); } // Update module states - if ( $context->shouldIncludeScripts() && !$context->getRaw() && !$isRaw ) { - if ( count( $modules ) && $context->getOnly() === 'scripts' ) { + if ( $context->shouldIncludeScripts() && !$context->getRaw() ) { + if ( $modules && $context->getOnly() === 'scripts' ) { // Set the state of modules loaded as only scripts to ready as // they don't have an mw.loader.implement wrapper that sets the state foreach ( $modules as $name => $module ) { @@ -1144,7 +1122,7 @@ MESSAGE; } // Set the state of modules we didn't respond to with mw.loader.implement - if ( count( $states ) ) { + if ( $states ) { $stateScript = self::makeLoaderStateScript( $states ); if ( !$context->getDebug() ) { $stateScript = self::filter( 'minify-js', $stateScript ); @@ -1265,11 +1243,9 @@ MESSAGE; * @return string JavaScript code */ public static function makeMessageSetScript( $messages ) { - return Xml::encodeJsCall( - 'mw.messages.set', - [ (object)$messages ], - self::inDebugMode() - ); + return 'mw.messages.set(' + . self::encodeJsonForScript( (object)$messages ) + . ');'; } /** @@ -1353,11 +1329,9 @@ MESSAGE; if ( !is_array( $states ) ) { $states = [ $states => $state ]; } - return Xml::encodeJsCall( - 'mw.loader.state', - [ $states ], - self::inDebugMode() - ); + return 'mw.loader.state(' + . self::encodeJsonForScript( $states ) + . ');'; } private static function isEmptyObject( stdClass $obj ) { @@ -1441,11 +1415,9 @@ MESSAGE; array_walk( $modules, [ self::class, 'trimArray' ] ); - return Xml::encodeJsCall( - 'mw.loader.register', - [ $modules ], - self::inDebugMode() - ); + return 'mw.loader.register(' + . self::encodeJsonForScript( $modules ) + . ');'; } /** @@ -1466,11 +1438,9 @@ MESSAGE; if ( !is_array( $sources ) ) { $sources = [ $sources => $loadUrl ]; } - return Xml::encodeJsCall( - 'mw.loader.addSource', - [ $sources ], - self::inDebugMode() - ); + return 'mw.loader.addSource(' + . self::encodeJsonForScript( $sources ) + . ');'; } /** @@ -1709,7 +1679,6 @@ MESSAGE; * @param bool $printable * @param bool $handheld * @param array $extraQuery - * * @return array */ public static function makeLoaderQuery( $modules, $lang, $skin, $user = null, @@ -1718,9 +1687,17 @@ MESSAGE; ) { $query = [ 'modules' => self::makePackedModulesString( $modules ), - 'lang' => $lang, - 'skin' => $skin, ]; + // Keep urls short by omitting query parameters that + // match the defaults assumed by ResourceLoaderContext. + // Note: This relies on the defaults either being insignificant or forever constant, + // as otherwise cached urls could change in meaning when the defaults change. + if ( $lang !== ResourceLoaderContext::DEFAULT_LANG ) { + $query['lang'] = $lang; + } + if ( $skin !== ResourceLoaderContext::DEFAULT_SKIN ) { + $query['skin'] = $skin; + } if ( $debug === true ) { $query['debug'] = 'true'; } diff --git a/includes/resourceloader/ResourceLoaderCircularDependencyError.php b/includes/resourceloader/ResourceLoaderCircularDependencyError.php new file mode 100644 index 0000000000..7cd53fe126 --- /dev/null +++ b/includes/resourceloader/ResourceLoaderCircularDependencyError.php @@ -0,0 +1,26 @@ + '1' ]; foreach ( [ 'target', 'safemode' ] as $param ) { if ( $this->options[$param] !== null ) { $startupQuery[$param] = (string)$this->options[$param]; @@ -348,7 +348,7 @@ JAVASCRIPT; private static function makeContext( ResourceLoaderContext $mainContext, $group, $type, array $extraQuery = [] ) { - // Create new ResourceLoaderContext so that $extraQuery may trigger isRaw(). + // Create new ResourceLoaderContext so that $extraQuery is supported (eg. for 'sync=1'). $req = new FauxRequest( array_merge( $mainContext->getRequest()->getValues(), $extraQuery ) ); // Set 'only' if not combined $req->setVal( 'only', $type === ResourceLoaderModule::TYPE_COMBINED ? null : $type ); @@ -434,12 +434,6 @@ JAVASCRIPT; ); } } else { - // See if we have one or more raw modules - $isRaw = false; - foreach ( $moduleSet as $key => $module ) { - $isRaw |= $module->isRaw(); - } - // Special handling for the user group; because users might change their stuff // on-wiki like user pages, or user preferences; we need to find the highest // timestamp of these user-changeable modules so we can ensure cache misses on change @@ -455,9 +449,15 @@ JAVASCRIPT; // Decide whether to use 'style' or 'script' element if ( $only === ResourceLoaderModule::TYPE_STYLES ) { $chunk = Html::linkedStyle( $url ); - } elseif ( $context->getRaw() || $isRaw ) { + } elseif ( $context->getRaw() ) { + // This request is asking for the module to be delivered standalone, + // (aka "raw") without communicating to any mw.loader client. + // Use cases: + // - startup (naturally because this is what will define mw.loader) + // - html5shiv (loads synchronously in old IE before the async startup module arrives) + // - QUnit (needed in SpecialJavaScriptTest before async startup) $chunk = Html::element( 'script', [ - // In SpecialJavaScriptTest, QUnit must load synchronous + // The 'sync' option is only supported in combination with 'raw'. 'async' => !isset( $extraQuery['sync'] ), 'src' => $url ] ); diff --git a/includes/resourceloader/ResourceLoaderContext.php b/includes/resourceloader/ResourceLoaderContext.php index 58152ea083..1f06ede1b7 100644 --- a/includes/resourceloader/ResourceLoaderContext.php +++ b/includes/resourceloader/ResourceLoaderContext.php @@ -30,6 +30,9 @@ use MediaWiki\MediaWikiServices; * of a specific loader request. */ class ResourceLoaderContext implements MessageLocalizer { + const DEFAULT_LANG = 'qqx'; + const DEFAULT_SKIN = 'fallback'; + protected $resourceLoader; protected $request; protected $logger; @@ -88,7 +91,7 @@ class ResourceLoaderContext implements MessageLocalizer { // The 'skin' parameter is required. (Not yet enforced.) // For requests without a known skin specified, // use MediaWiki's 'fallback' skin for skin-specific decisions. - $this->skin = 'fallback'; + $this->skin = self::DEFAULT_SKIN; } } @@ -134,6 +137,8 @@ class ResourceLoaderContext implements MessageLocalizer { } /** + * @deprecated since 1.34 Use ResourceLoaderModule::getConfig instead + * inside module methods. Use ResourceLoader::getConfig elsewhere. * @return Config */ public function getConfig() { @@ -148,6 +153,8 @@ class ResourceLoaderContext implements MessageLocalizer { } /** + * @deprecated since 1.34 Use ResourceLoaderModule::getLogger instead + * inside module methods. Use ResourceLoader::getLogger elsewhere. * @since 1.27 * @return \Psr\Log\LoggerInterface */ @@ -174,7 +181,7 @@ class ResourceLoaderContext implements MessageLocalizer { if ( !Language::isValidBuiltInCode( $lang ) ) { // The 'lang' parameter is required. (Not yet enforced.) // If omitted, localise with the dummy language code. - $lang = 'qqx'; + $lang = self::DEFAULT_LANG; } $this->language = $lang; } @@ -186,8 +193,10 @@ class ResourceLoaderContext implements MessageLocalizer { */ public function getDirection() { if ( $this->direction === null ) { - $this->direction = $this->getRequest()->getRawVal( 'dir' ); - if ( !$this->direction ) { + $direction = $this->getRequest()->getRawVal( 'dir' ); + if ( $direction === 'ltr' || $direction === 'rtl' ) { + $this->direction = $direction; + } else { // Determine directionality based on user language (T8100) $this->direction = Language::factory( $this->getLanguage() )->getDir(); } @@ -401,4 +410,24 @@ class ResourceLoaderContext implements MessageLocalizer { } return $this->hash; } + + /** + * Get the request base parameters, omitting any defaults. + * + * @internal For internal use by ResourceLoaderStartUpModule only + * @return array + */ + public function getReqBase() { + $reqBase = []; + if ( $this->getLanguage() !== self::DEFAULT_LANG ) { + $reqBase['lang'] = $this->getLanguage(); + } + if ( $this->getSkin() !== self::DEFAULT_SKIN ) { + $reqBase['skin'] = $this->getSkin(); + } + if ( $this->getDebug() ) { + $reqBase['debug'] = 'true'; + } + return $reqBase; + } } diff --git a/includes/resourceloader/ResourceLoaderFileModule.php b/includes/resourceloader/ResourceLoaderFileModule.php index 031541b559..af303138f6 100644 --- a/includes/resourceloader/ResourceLoaderFileModule.php +++ b/includes/resourceloader/ResourceLoaderFileModule.php @@ -24,6 +24,12 @@ /** * ResourceLoader module based on local JavaScript/CSS files. + * + * The following public methods can query the database: + * + * - getDefinitionSummary / … / ResourceLoaderModule::getFileDependencies. + * - getVersionHash / getDefinitionSummary / … / ResourceLoaderModule::getFileDependencies. + * - getStyles / ResourceLoaderModule::saveFileDependencies. */ class ResourceLoaderFileModule extends ResourceLoaderModule { @@ -134,9 +140,6 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { /** @var bool Link to raw files in debug mode */ protected $debugRaw = true; - /** @var bool Whether mw.loader.state() call should be omitted */ - protected $raw = false; - protected $targets = [ 'desktop' ]; /** @var bool Whether CSSJanus flipping should be skipped for this module */ @@ -253,11 +256,11 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { case 'debugScripts': case 'styles': case 'packageFiles': - $this->{$member} = (array)$option; + $this->{$member} = is_array( $option ) ? $option : [ $option ]; break; case 'templates': $hasTemplates = true; - $this->{$member} = (array)$option; + $this->{$member} = is_array( $option ) ? $option : [ $option ]; break; // Collated lists of file paths case 'languageScripts': @@ -276,7 +279,7 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { "'$key' given, string expected." ); } - $this->{$member}[$key] = (array)$value; + $this->{$member}[$key] = is_array( $value ) ? $value : [ $value ]; } break; case 'deprecated': @@ -299,7 +302,6 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { break; // Single booleans case 'debugRaw': - case 'raw': case 'noflip': $this->{$member} = (bool)$option; break; @@ -313,7 +315,7 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { // Ensure relevant template compiler module gets loaded foreach ( $this->templates as $alias => $templatePath ) { if ( is_int( $alias ) ) { - $alias = $templatePath; + $alias = $this->getPath( $templatePath ); } $suffix = explode( '.', $alias ); $suffix = end( $suffix ); @@ -334,7 +336,7 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { * to $IP * @param string|null $remoteBasePath Path to use if not provided in module definition. Defaults * to $wgResourceBasePath - * @return array Array( localBasePath, remoteBasePath ) + * @return array [ localBasePath, remoteBasePath ] */ public static function extractBasePaths( $options = [], @@ -507,13 +509,6 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { return $contents; } - /** - * @return bool - */ - public function isRaw() { - return $this->raw; - } - /** * Disable module content versioning. * @@ -614,14 +609,28 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { 'templates', 'skipFunction', 'debugRaw', - 'raw', ] as $member ) { $options[$member] = $this->{$member}; - }; + } + + $packageFiles = $this->expandPackageFiles( $context ); + if ( $packageFiles ) { + // Extract the minimum needed: + // - The 'main' pointer (included as-is). + // - The 'files' array, simplied to only which files exist (the keys of + // this array), and something that represents their non-file content. + // For packaged files that reflect files directly from disk, the + // 'getFileHashes' method tracks this already. + // It is important that the keys of the 'files' array are preserved, + // as they affect the module output. + $packageFiles['files'] = array_map( function ( $fileInfo ) { + return $fileInfo['definitionSummary'] ?? ( $fileInfo['content'] ?? null ); + }, $packageFiles['files'] ); + } $summary[] = [ 'options' => $options, - 'packageFiles' => $this->expandPackageFiles( $context ), + 'packageFiles' => $packageFiles, 'fileHashes' => $this->getFileHashes( $context ), 'messageBlob' => $this->getMessageBlob( $context ), ]; @@ -634,6 +643,18 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { return $summary; } + /** + * @param string|ResourceLoaderFilePath $path + * @return string + */ + protected function getPath( $path ) { + if ( $path instanceof ResourceLoaderFilePath ) { + return $path->getPath(); + } + + return $path; + } + /** * @param string|ResourceLoaderFilePath $path * @return string @@ -983,7 +1004,6 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { || $this->dependencies || $this->messages || $this->skipFunction - || $this->raw ); return $canBeStylesOnly ? self::LOAD_STYLES : self::LOAD_GENERAL; } @@ -1052,7 +1072,7 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { foreach ( $this->templates as $alias => $templatePath ) { // Alias is optional if ( is_int( $alias ) ) { - $alias = $templatePath; + $alias = $this->getPath( $templatePath ); } $localPath = $this->getLocalPath( $templatePath ); if ( file_exists( $localPath ) ) { @@ -1068,16 +1088,22 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { } /** - * Expand the packageFiles definition into something that's (almost) the right format for - * getPackageFiles() to return. This expands shorthands, resolves config vars and callbacks, - * but does not expand file paths or read the actual contents of files. Those things are done - * by getPackageFiles(). + * Internal helper for use by getPackageFiles(), getFileHashes() and getDefinitionSummary(). + * + * This expands the 'packageFiles' definition into something that's (almost) the right format + * for getPackageFiles() to return. It expands shorthands, resolves config vars, and handles + * summarising any non-file data for getVersionHash(). For file-based data, getFileHashes() + * handles it instead, which also ends up in getDefinitionSummary(). * - * This is split up in this way so that getFileHashes() can get a list of file names, and - * getDefinitionSummary() can get config vars and callback results in their expanded form. + * What it does not do is reading the actual contents of any specified files, nor invoking + * the computation callbacks. Those things are done by getPackageFiles() instead to improve + * backend performance by only doing this work when the module response is needed, and not + * when merely computing the version hash for StartupModule, or when checking + * If-None-Match headers for a HTTP 304 response. * * @param ResourceLoaderContext $context * @return array|null + * @throws MWException If the 'packageFiles' definition is invalid. */ private function expandPackageFiles( ResourceLoaderContext $context ) { $hash = $context->getHash(); @@ -1113,19 +1139,33 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { } } + // Perform expansions (except 'file' and 'callback'), creating one of these keys: + // - 'content': literal value. + // - 'filePath': content to be read from a file. + // - 'callback': content computed by a callable. if ( isset( $fileInfo['content'] ) ) { $expanded['content'] = $fileInfo['content']; } elseif ( isset( $fileInfo['file'] ) ) { $expanded['filePath'] = $fileInfo['file']; } elseif ( isset( $fileInfo['callback'] ) ) { - if ( is_callable( $fileInfo['callback'] ) ) { - $expanded['content'] = $fileInfo['callback']( $context ); - } else { + if ( !is_callable( $fileInfo['callback'] ) ) { $msg = __METHOD__ . ": invalid callback for package file \"{$fileInfo['name']}\"" . " in module \"{$this->getName()}\""; wfDebugLog( 'resourceloader', $msg ); throw new MWException( $msg ); } + if ( isset( $fileInfo['versionCallback'] ) ) { + if ( !is_callable( $fileInfo['versionCallback'] ) ) { + throw new MWException( __METHOD__ . ": invalid versionCallback for file" . + " \"{$fileInfo['name']}\" in module \"{$this->getName()}\"" ); + } + $expanded['definitionSummary'] = + ( $fileInfo['versionCallback'] )( $context, $this->getConfig() ); + // Don't invoke 'callback' here as it may be expensive (T223260). + $expanded['callback'] = $fileInfo['callback']; + } else { + $expanded['content'] = ( $fileInfo['callback'] )( $context, $this->getConfig() ); + } } elseif ( isset( $fileInfo['config'] ) ) { if ( $type !== 'data' ) { $msg = __METHOD__ . ": invalid use of \"config\" for package file \"{$fileInfo['name']}\" " . @@ -1184,6 +1224,8 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { // Expand file contents foreach ( $expandedPackageFiles['files'] as &$fileInfo ) { + // Turn any 'filePath' or 'callback' key into actual 'content', + // and remove the key after that. if ( isset( $fileInfo['filePath'] ) ) { $localPath = $this->getLocalPath( $fileInfo['filePath'] ); if ( !file_exists( $localPath ) ) { @@ -1198,7 +1240,13 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { } $fileInfo['content'] = $content; unset( $fileInfo['filePath'] ); + } elseif ( isset( $fileInfo['callback'] ) ) { + $fileInfo['content'] = ( $fileInfo['callback'] )( $context, $this->getConfig() ); + unset( $fileInfo['callback'] ); } + + // Not needed for client response, exists for getDefinitionSummary(). + unset( $fileInfo['definitionSummary'] ); } return $expandedPackageFiles; diff --git a/includes/resourceloader/ResourceLoaderFilePath.php b/includes/resourceloader/ResourceLoaderFilePath.php index 3cf09d82e1..c01e507455 100644 --- a/includes/resourceloader/ResourceLoaderFilePath.php +++ b/includes/resourceloader/ResourceLoaderFilePath.php @@ -62,6 +62,20 @@ class ResourceLoaderFilePath { return "{$this->remoteBasePath}/{$this->path}"; } + /** + * @return string + */ + public function getLocalBasePath() { + return $this->localBasePath; + } + + /** + * @return string + */ + public function getRemoteBasePath() { + return $this->remoteBasePath; + } + /** * @return string */ diff --git a/includes/resourceloader/ResourceLoaderImage.php b/includes/resourceloader/ResourceLoaderImage.php index 2e2da70475..900395108b 100644 --- a/includes/resourceloader/ResourceLoaderImage.php +++ b/includes/resourceloader/ResourceLoaderImage.php @@ -95,9 +95,9 @@ class ResourceLoaderImage { // Ensure that all files have common extension. $extensions = []; - $descriptor = (array)$this->descriptor; + $descriptor = is_array( $this->descriptor ) ? $this->descriptor : [ $this->descriptor ]; array_walk_recursive( $descriptor, function ( $path ) use ( &$extensions ) { - $extensions[] = pathinfo( $path, PATHINFO_EXTENSION ); + $extensions[] = pathinfo( $this->getLocalPath( $path ), PATHINFO_EXTENSION ); } ); $extensions = array_unique( $extensions ); if ( count( $extensions ) !== 1 ) { @@ -146,28 +146,45 @@ class ResourceLoaderImage { * * @param ResourceLoaderContext $context Any context * @return string + * @throws MWException If no matching path is found */ public function getPath( ResourceLoaderContext $context ) { $desc = $this->descriptor; - if ( is_string( $desc ) ) { - return $this->basePath . '/' . $desc; + if ( !is_array( $desc ) ) { + return $this->getLocalPath( $desc ); } if ( isset( $desc['lang'] ) ) { $contextLang = $context->getLanguage(); if ( isset( $desc['lang'][$contextLang] ) ) { - return $this->basePath . '/' . $desc['lang'][$contextLang]; + return $this->getLocalPath( $desc['lang'][$contextLang] ); } $fallbacks = Language::getFallbacksFor( $contextLang, Language::STRICT_FALLBACKS ); foreach ( $fallbacks as $lang ) { if ( isset( $desc['lang'][$lang] ) ) { - return $this->basePath . '/' . $desc['lang'][$lang]; + return $this->getLocalPath( $desc['lang'][$lang] ); } } } if ( isset( $desc[$context->getDirection()] ) ) { - return $this->basePath . '/' . $desc[$context->getDirection()]; + return $this->getLocalPath( $desc[$context->getDirection()] ); + } + if ( isset( $desc['default'] ) ) { + return $this->getLocalPath( $desc['default'] ); + } else { + throw new MWException( 'No matching path found' ); } - return $this->basePath . '/' . $desc['default']; + } + + /** + * @param string|ResourceLoaderFilePath $path + * @return string + */ + protected function getLocalPath( $path ) { + if ( $path instanceof ResourceLoaderFilePath ) { + return $path->getLocalPath(); + } + + return "{$this->basePath}/$path"; } /** @@ -209,10 +226,13 @@ class ResourceLoaderImage { 'image' => $this->getName(), 'variant' => $variant, 'format' => $format, - 'lang' => $context->getLanguage(), - 'skin' => $context->getSkin(), - 'version' => $context->getVersion(), ]; + if ( $this->varyOnLanguage() ) { + $query['lang'] = $context->getLanguage(); + } + // The following parameters are at the end to keep the original order of the parameters. + $query['skin'] = $context->getSkin(); + $query['version'] = $context->getVersion(); return wfAppendQuery( $script, $query ); } @@ -441,4 +461,16 @@ class ResourceLoaderImage { return $png ?: false; } } + + /** + * Check if the image depends on the language. + * + * @return bool + */ + private function varyOnLanguage() { + return is_array( $this->descriptor ) && ( + isset( $this->descriptor['ltr'] ) || + isset( $this->descriptor['rtl'] ) || + isset( $this->descriptor['lang'] ) ); + } } diff --git a/includes/resourceloader/ResourceLoaderImageModule.php b/includes/resourceloader/ResourceLoaderImageModule.php index 9b50d80f69..902fa91b79 100644 --- a/includes/resourceloader/ResourceLoaderImageModule.php +++ b/includes/resourceloader/ResourceLoaderImageModule.php @@ -39,7 +39,7 @@ class ResourceLoaderImageModule extends ResourceLoaderModule { protected $origin = self::ORIGIN_CORE_SITEWIDE; - /** @var ResourceLoaderImage[]|null */ + /** @var ResourceLoaderImage[][]|null */ protected $imageObjects = null; /** @var array */ protected $images = []; @@ -113,7 +113,7 @@ class ResourceLoaderImageModule extends ResourceLoaderModule { * @throws InvalidArgumentException */ public function __construct( $options = [], $localBasePath = null ) { - $this->localBasePath = self::extractLocalBasePath( $options, $localBasePath ); + $this->localBasePath = static::extractLocalBasePath( $options, $localBasePath ); $this->definition = $options; } @@ -130,7 +130,7 @@ class ResourceLoaderImageModule extends ResourceLoaderModule { $this->definition = null; if ( isset( $options['data'] ) ) { - $dataPath = $this->localBasePath . '/' . $options['data']; + $dataPath = $this->getLocalPath( $options['data'] ); $data = json_decode( file_get_contents( $dataPath ), true ); $options = array_merge( $data, $options ); } @@ -259,7 +259,7 @@ class ResourceLoaderImageModule extends ResourceLoaderModule { $this->images[$skin] = $this->images['default'] ?? []; } foreach ( $this->images[$skin] as $name => $options ) { - $fileDescriptor = is_string( $options ) ? $options : $options['file']; + $fileDescriptor = is_array( $options ) ? $options['file'] : $options; $allowedVariants = array_merge( ( is_array( $options ) && isset( $options['variants'] ) ) ? $options['variants'] : [], @@ -452,6 +452,18 @@ class ResourceLoaderImageModule extends ResourceLoaderModule { return array_map( [ __CLASS__, 'safeFileHash' ], $files ); } + /** + * @param string|ResourceLoaderFilePath $path + * @return string + */ + protected function getLocalPath( $path ) { + if ( $path instanceof ResourceLoaderFilePath ) { + return $path->getLocalPath(); + } + + return "{$this->localBasePath}/$path"; + } + /** * Extract a local base path from module definition information. * diff --git a/includes/resourceloader/ResourceLoaderLanguageDataModule.php b/includes/resourceloader/ResourceLoaderLanguageDataModule.php index f718e5feb3..7a7ab892ce 100644 --- a/includes/resourceloader/ResourceLoaderLanguageDataModule.php +++ b/includes/resourceloader/ResourceLoaderLanguageDataModule.php @@ -1,7 +1,5 @@ getSkin() . '|' . $context->getLanguage(); + $vary = self::getVary( $context ); // Try in-object cache first if ( !isset( $this->fileDeps[$vary] ) ) { @@ -447,7 +433,7 @@ abstract class ResourceLoaderModule implements LoggerAwareInterface { * @param string[] $files Array of file names */ public function setFileDependencies( ResourceLoaderContext $context, $files ) { - $vary = $context->getSkin() . '|' . $context->getLanguage(); + $vary = self::getVary( $context ); $this->fileDeps[$vary] = $files; } @@ -484,7 +470,7 @@ abstract class ResourceLoaderModule implements LoggerAwareInterface { } // The file deps list has changed, we want to update it. - $vary = $context->getSkin() . '|' . $context->getLanguage(); + $vary = self::getVary( $context ); $cache = ObjectCache::getLocalClusterInstance(); $key = $cache->makeKey( __METHOD__, $this->getName(), $vary ); $scopeLock = $cache->getScopedLock( $key, 0 ); @@ -954,8 +940,7 @@ abstract class ResourceLoaderModule implements LoggerAwareInterface { $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); return $cache->getWithSetCallback( $cache->makeGlobalKey( - 'resourceloader', - 'jsparse', + 'resourceloader-jsparse', self::$parseCacheVersion, md5( $contents ), $fileName @@ -1021,4 +1006,18 @@ abstract class ResourceLoaderModule implements LoggerAwareInterface { protected static function safeFileHash( $filePath ) { return FileContentsHasher::getFileContentsHash( $filePath ); } + + /** + * Get vary string. + * + * @internal For internal use only. + * @param ResourceLoaderContext $context + * @return string Vary string + */ + public static function getVary( ResourceLoaderContext $context ) { + return implode( '|', [ + $context->getSkin(), + $context->getLanguage(), + ] ); + } } diff --git a/includes/resourceloader/ResourceLoaderOOUIIconPackModule.php b/includes/resourceloader/ResourceLoaderOOUIIconPackModule.php index 0c70ee1266..c860362af7 100644 --- a/includes/resourceloader/ResourceLoaderOOUIIconPackModule.php +++ b/includes/resourceloader/ResourceLoaderOOUIIconPackModule.php @@ -69,4 +69,13 @@ class ResourceLoaderOOUIIconPackModule extends ResourceLoaderOOUIImageModule { return $definition; } + + public static function extractLocalBasePath( $options, $localBasePath = null ) { + global $IP; + if ( $localBasePath === null ) { + $localBasePath = $IP; + } + // Ignore any 'localBasePath' present in $options, this always refers to files in MediaWiki core + return $localBasePath; + } } diff --git a/includes/resourceloader/ResourceLoaderOOUIImageModule.php b/includes/resourceloader/ResourceLoaderOOUIImageModule.php index 34079c3b7b..c6d4cdfde3 100644 --- a/includes/resourceloader/ResourceLoaderOOUIImageModule.php +++ b/includes/resourceloader/ResourceLoaderOOUIImageModule.php @@ -97,6 +97,9 @@ class ResourceLoaderOOUIImageModule extends ResourceLoaderImageModule { // Find the path to the JSON file which contains the actual image definitions for this theme if ( $module ) { $dataPath = $this->getThemeImagesPath( $theme, $module ); + if ( !$dataPath ) { + return false; + } } else { // Backwards-compatibility for things that probably shouldn't have used this class... $dataPath = @@ -116,7 +119,7 @@ class ResourceLoaderOOUIImageModule extends ResourceLoaderImageModule { * @return array|false */ protected function readJSONFile( $dataPath ) { - $localDataPath = $this->localBasePath . '/' . $dataPath; + $localDataPath = $this->getLocalPath( $dataPath ); if ( !file_exists( $localDataPath ) ) { return false; @@ -127,7 +130,15 @@ class ResourceLoaderOOUIImageModule extends ResourceLoaderImageModule { // Expand the paths to images (since they are relative to the JSON file that defines them, not // our base directory) $fixPath = function ( &$path ) use ( $dataPath ) { - $path = dirname( $dataPath ) . '/' . $path; + if ( $dataPath instanceof ResourceLoaderFilePath ) { + $path = new ResourceLoaderFilePath( + dirname( $dataPath->getPath() ) . '/' . $path, + $dataPath->getLocalBasePath(), + $dataPath->getRemoteBasePath() + ); + } else { + $path = dirname( $dataPath ) . '/' . $path; + } }; array_walk( $data['images'], function ( &$value ) use ( $fixPath ) { if ( is_string( $value['file'] ) ) { diff --git a/includes/resourceloader/ResourceLoaderOOUIModule.php b/includes/resourceloader/ResourceLoaderOOUIModule.php index 0395127c57..fdcc2135e2 100644 --- a/includes/resourceloader/ResourceLoaderOOUIModule.php +++ b/includes/resourceloader/ResourceLoaderOOUIModule.php @@ -27,7 +27,7 @@ trait ResourceLoaderOOUIModule { protected static $knownScriptsModules = [ 'core' ]; protected static $knownStylesModules = [ 'core', 'widgets', 'toolbars', 'windows' ]; protected static $knownImagesModules = [ - 'indicators', 'textures', + 'indicators', // Extra icons 'icons-accessibility', 'icons-alerts', @@ -82,7 +82,7 @@ trait ResourceLoaderOOUIModule { * Return a map of theme names to lists of paths from which a given theme should be loaded. * * Keys are theme names, values are associative arrays. Keys of the inner array are 'scripts', - * 'styles', or 'images', and values are string paths. + * 'styles', or 'images', and values are paths. Paths may be strings or ResourceLoaderFilePaths. * * Additionally, the string '{module}' in paths represents the name of the module to load. * @@ -90,29 +90,57 @@ trait ResourceLoaderOOUIModule { */ protected static function getThemePaths() { $themePaths = self::$builtinThemePaths; + $themePaths += ExtensionRegistry::getInstance()->getAttribute( 'OOUIThemePaths' ); + + list( $defaultLocalBasePath, $defaultRemoteBasePath ) = + ResourceLoaderFileModule::extractBasePaths(); + + // Allow custom themes' paths to be relative to the skin/extension that defines them, + // like with ResourceModuleSkinStyles + foreach ( $themePaths as $theme => &$paths ) { + list( $localBasePath, $remoteBasePath ) = + ResourceLoaderFileModule::extractBasePaths( $paths ); + if ( $localBasePath !== $defaultLocalBasePath || $remoteBasePath !== $defaultRemoteBasePath ) { + foreach ( $paths as &$path ) { + $path = new ResourceLoaderFilePath( $path, $localBasePath, $remoteBasePath ); + } + } + } + return $themePaths; } /** * Return a path to load given module of given theme from. * + * The file at this path may not exist. This should be handled by the caller (throwing an error or + * falling back to default theme). + * * @param string $theme OOUI theme name, for example 'WikimediaUI' or 'Apex' * @param string $kind Kind of the module: 'scripts', 'styles', or 'images' * @param string $module Module name, for valid values see $knownScriptsModules, * $knownStylesModules, $knownImagesModules - * @return string + * @return string|ResourceLoaderFilePath */ protected function getThemePath( $theme, $kind, $module ) { $paths = self::getThemePaths(); $path = $paths[$theme][$kind]; - $path = str_replace( '{module}', $module, $path ); + if ( $path instanceof ResourceLoaderFilePath ) { + $path = new ResourceLoaderFilePath( + str_replace( '{module}', $module, $path->getPath() ), + $path->getLocalBasePath(), + $path->getRemoteBasePath() + ); + } else { + $path = str_replace( '{module}', $module, $path ); + } return $path; } /** * @param string $theme See getThemePath() * @param string $module See getThemePath() - * @return string + * @return string|ResourceLoaderFilePath */ protected function getThemeScriptsPath( $theme, $module ) { if ( !in_array( $module, self::$knownScriptsModules ) ) { @@ -124,7 +152,7 @@ trait ResourceLoaderOOUIModule { /** * @param string $theme See getThemePath() * @param string $module See getThemePath() - * @return string + * @return string|ResourceLoaderFilePath */ protected function getThemeStylesPath( $theme, $module ) { if ( !in_array( $module, self::$knownStylesModules ) ) { @@ -136,7 +164,7 @@ trait ResourceLoaderOOUIModule { /** * @param string $theme See getThemePath() * @param string $module See getThemePath() - * @return string + * @return string|ResourceLoaderFilePath */ protected function getThemeImagesPath( $theme, $module ) { if ( !in_array( $module, self::$knownImagesModules ) ) { diff --git a/includes/resourceloader/ResourceLoaderSkinModule.php b/includes/resourceloader/ResourceLoaderSkinModule.php index 2dd6c17ddd..0f33666216 100644 --- a/includes/resourceloader/ResourceLoaderSkinModule.php +++ b/includes/resourceloader/ResourceLoaderSkinModule.php @@ -1,7 +1,5 @@ getSkin(); $vars = [ - 'wgLoadScript' => $conf->get( 'LoadScript' ), 'debug' => $context->getDebug(), 'skin' => $skin, 'stylepath' => $conf->get( 'StylePath' ), @@ -106,7 +105,6 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { 'wgCaseSensitiveNamespaces' => $caseSensitiveNamespaces, 'wgLegalTitleChars' => Title::convertByteClassToUnicodeClass( Title::legalChars() ), 'wgIllegalFileChars' => Title::convertByteClassToUnicodeClass( $illegalFileChars ), - 'wgResourceLoaderStorageVersion' => $conf->get( 'ResourceLoaderStorageVersion' ), 'wgResourceLoaderStorageEnabled' => $conf->get( 'ResourceLoaderStorageEnabled' ), 'wgForeignUploadTargets' => $conf->get( 'ForeignUploadTargets' ), 'wgEnableUploads' => $conf->get( 'EnableUploads' ), @@ -124,29 +122,50 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { * * @param array $registryData * @param string $moduleName + * @param string[] $handled Internal parameter for recursion. (Optional) * @return array + * @throws ResourceLoaderCircularDependencyError */ - protected static function getImplicitDependencies( array $registryData, $moduleName ) { + protected static function getImplicitDependencies( + array $registryData, + $moduleName, + array $handled = [] + ) { static $dependencyCache = []; - // The list of implicit dependencies won't be altered, so we can - // cache them without having to worry. + // No modules will be added or changed server-side after this point, + // so we can safely cache parts of the tree for re-use. if ( !isset( $dependencyCache[$moduleName] ) ) { if ( !isset( $registryData[$moduleName] ) ) { - // Dependencies may not exist - $dependencyCache[$moduleName] = []; + // Unknown module names are allowed here, this is only an optimisation. + // Checks for illegal and unknown dependencies happen as PHPUnit structure tests, + // and also client-side at run-time. + $flat = []; } else { $data = $registryData[$moduleName]; - $dependencyCache[$moduleName] = $data['dependencies']; + $flat = $data['dependencies']; + // Prevent recursion + $handled[] = $moduleName; foreach ( $data['dependencies'] as $dependency ) { - // Recursively get the dependencies of the dependencies - $dependencyCache[$moduleName] = array_merge( - $dependencyCache[$moduleName], - self::getImplicitDependencies( $registryData, $dependency ) - ); + if ( in_array( $dependency, $handled, true ) ) { + // If we encounter a circular dependency, then stop the optimiser and leave the + // original dependencies array unmodified. Circular dependencies are not + // supported in ResourceLoader. Awareness of them exists here so that we can + // optimise the registry when it isn't broken, and otherwise transport the + // registry unchanged. The client will handle this further. + throw new ResourceLoaderCircularDependencyError(); + } else { + // Recursively add the dependencies of the dependencies + $flat = array_merge( + $flat, + self::getImplicitDependencies( $registryData, $dependency, $handled ) + ); + } } } + + $dependencyCache[$moduleName] = $flat; } return $dependencyCache[$moduleName]; @@ -173,10 +192,16 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { public static function compileUnresolvedDependencies( array &$registryData ) { foreach ( $registryData as $name => &$data ) { $dependencies = $data['dependencies']; - foreach ( $data['dependencies'] as $dependency ) { - $implicitDependencies = self::getImplicitDependencies( $registryData, $dependency ); - $dependencies = array_diff( $dependencies, $implicitDependencies ); + try { + foreach ( $data['dependencies'] as $dependency ) { + $implicitDependencies = self::getImplicitDependencies( $registryData, $dependency ); + $dependencies = array_diff( $dependencies, $implicitDependencies ); + } + } catch ( ResourceLoaderCircularDependencyError $err ) { + // Leave unchanged + $dependencies = $data['dependencies']; } + // Rebuild keys $data['dependencies'] = array_values( $dependencies ); } @@ -227,10 +252,11 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { continue; } - if ( $module->isRaw() ) { - // Don't register "raw" modules (like 'startup') client-side because depending on them - // is illegal anyway and would only lead to them being loaded a second time, - // causing any state to be lost. + if ( $module instanceof ResourceLoaderStartUpModule ) { + // Don't register 'startup' to the client because loading it lazily or depending + // on it doesn't make sense, because the startup module *is* the client. + // Registering would be a waste of bandwidth and memory and risks somehow causing + // it to load a second time. // ATTENTION: Because of the line below, this is not going to cause infinite recursion. // Think carefully before making changes to this code! @@ -258,7 +284,7 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { } if ( $versionHash !== '' && strlen( $versionHash ) !== 7 ) { - $context->getLogger()->warning( + $this->getLogger()->warning( "Module '{module}' produced an invalid version hash: '{version}'.", [ 'module' => $name, @@ -314,17 +340,11 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { return $out; } - /** - * @return bool - */ - public function isRaw() { - return true; - } - /** * @private For internal use by SpecialJavaScriptTest * @since 1.32 * @return array + * @codeCoverageIgnore */ public function getBaseModulesInternal() { return $this->getBaseModules(); @@ -346,6 +366,30 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { return $baseModules; } + /** + * Get the localStorage key for the entire module store. The key references + * $wgDBname to prevent clashes between wikis under the same web domain. + * + * @return string localStorage item key for JavaScript + */ + private function getStoreKey() { + return 'MediaWikiModuleStore:' . $this->getConfig()->get( 'DBname' ); + } + + /** + * Get the key on which the JavaScript module cache (mw.loader.store) will vary. + * + * @param ResourceLoaderContext $context + * @return string String of concatenated vary conditions + */ + private function getStoreVary( ResourceLoaderContext $context ) { + return implode( ':', [ + $context->getSkin(), + $this->getConfig()->get( 'ResourceLoaderStorageVersion' ), + $context->getLanguage(), + ] ); + } + /** * @param ResourceLoaderContext $context * @return string JavaScript code @@ -373,10 +417,13 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { // Perform replacements for mediawiki.js $mwLoaderPairs = [ + '$VARS.reqBase' => ResourceLoader::encodeJsonForScript( $context->getReqBase() ), '$VARS.baseModules' => ResourceLoader::encodeJsonForScript( $this->getBaseModules() ), '$VARS.maxQueryLength' => ResourceLoader::encodeJsonForScript( $conf->get( 'ResourceLoaderMaxQueryLength' ) ), + '$VARS.storeKey' => ResourceLoader::encodeJsonForScript( $this->getStoreKey() ), + '$VARS.storeVary' => ResourceLoader::encodeJsonForScript( $this->getStoreVary( $context ) ), ]; $profilerStubs = [ '$CODE.profileExecuteStart();' => 'mw.loader.profiler.onExecuteStart( module );', @@ -425,11 +472,4 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { // and hash it to determine the version (as used by E-Tag HTTP response header). return true; } - - /** - * @return string - */ - public function getGroup() { - return 'startup'; - } } diff --git a/includes/resourceloader/ResourceLoaderWikiModule.php b/includes/resourceloader/ResourceLoaderWikiModule.php index 4c11fce0b1..b1040733a0 100644 --- a/includes/resourceloader/ResourceLoaderWikiModule.php +++ b/includes/resourceloader/ResourceLoaderWikiModule.php @@ -23,6 +23,7 @@ */ use MediaWiki\Linker\LinkTarget; +use MediaWiki\Storage\RevisionRecord; use Wikimedia\Assert\Assert; use Wikimedia\Rdbms\Database; use Wikimedia\Rdbms\IDatabase; @@ -220,7 +221,7 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule { if ( !$revision ) { return null; } - $content = $revision->getContent( Revision::RAW ); + $content = $revision->getContent( RevisionRecord::RAW ); if ( !$content ) { $this->getLogger()->error( @@ -484,7 +485,7 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule { $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); $allInfo = $cache->getWithSetCallback( - $cache->makeGlobalKey( 'resourceloader', 'titleinfo', $db->getDomainID(), $hash ), + $cache->makeGlobalKey( 'resourceloader-titleinfo', $db->getDomainID(), $hash ), $cache::TTL_HOUR, function ( $curVal, &$ttl, array &$setOpts ) use ( $func, $pageNames, $db, $fname ) { $setOpts += Database::getCacheSetOptions( $db ); @@ -493,7 +494,7 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule { }, [ 'checkKeys' => [ - $cache->makeGlobalKey( 'resourceloader', 'titleinfo', $db->getDomainID() ) ] + $cache->makeGlobalKey( 'resourceloader-titleinfo', $db->getDomainID() ) ] ] ); @@ -550,7 +551,7 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule { if ( $purge ) { $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); - $key = $cache->makeGlobalKey( 'resourceloader', 'titleinfo', $domain ); + $key = $cache->makeGlobalKey( 'resourceloader-titleinfo', $domain ); $cache->touchCheckKey( $key ); } } diff --git a/includes/revisiondelete/RevDelArchivedFileItem.php b/includes/revisiondelete/RevDelArchivedFileItem.php index 00e40a0b5e..ab9830f706 100644 --- a/includes/revisiondelete/RevDelArchivedFileItem.php +++ b/includes/revisiondelete/RevDelArchivedFileItem.php @@ -19,6 +19,8 @@ * @ingroup RevisionDelete */ +use MediaWiki\Storage\RevisionRecord; + /** * Item class for a filearchive table row */ @@ -109,8 +111,8 @@ class RevDelArchivedFileItem extends RevDelFileItem { 'width' => $file->getWidth(), 'height' => $file->getHeight(), 'size' => $file->getSize(), - 'userhidden' => (bool)$file->isDeleted( Revision::DELETED_USER ), - 'commenthidden' => (bool)$file->isDeleted( Revision::DELETED_COMMENT ), + 'userhidden' => (bool)$file->isDeleted( RevisionRecord::DELETED_USER ), + 'commenthidden' => (bool)$file->isDeleted( RevisionRecord::DELETED_COMMENT ), 'contenthidden' => (bool)$this->isDeleted(), ]; if ( $this->canViewContent() ) { @@ -124,13 +126,13 @@ class RevDelArchivedFileItem extends RevDelFileItem { ), ]; } - if ( $file->userCan( Revision::DELETED_USER, $user ) ) { + if ( $file->userCan( RevisionRecord::DELETED_USER, $user ) ) { $ret += [ 'userid' => $file->getUser( 'id' ), 'user' => $file->getUser( 'text' ), ]; } - if ( $file->userCan( Revision::DELETED_COMMENT, $user ) ) { + if ( $file->userCan( RevisionRecord::DELETED_COMMENT, $user ) ) { $ret += [ 'comment' => $file->getRawDescription(), ]; diff --git a/includes/revisiondelete/RevDelFileItem.php b/includes/revisiondelete/RevDelFileItem.php index c7941b7658..8c080baf83 100644 --- a/includes/revisiondelete/RevDelFileItem.php +++ b/includes/revisiondelete/RevDelFileItem.php @@ -19,6 +19,8 @@ * @ingroup RevisionDelete */ +use MediaWiki\Storage\RevisionRecord; + /** * Item class for an oldimage table row */ @@ -164,14 +166,14 @@ class RevDelFileItem extends RevDelItem { * @return string HTML */ protected function getUserTools() { - if ( $this->file->userCan( Revision::DELETED_USER, $this->list->getUser() ) ) { + if ( $this->file->userCan( RevisionRecord::DELETED_USER, $this->list->getUser() ) ) { $uid = $this->file->getUser( 'id' ); $name = $this->file->getUser( 'text' ); $link = Linker::userLink( $uid, $name ) . Linker::userToolLinks( $uid, $name ); } else { $link = $this->list->msg( 'rev-deleted-user' )->escaped(); } - if ( $this->file->isDeleted( Revision::DELETED_USER ) ) { + if ( $this->file->isDeleted( RevisionRecord::DELETED_USER ) ) { return '' . $link . ''; } @@ -217,8 +219,8 @@ class RevDelFileItem extends RevDelItem { 'width' => $file->getWidth(), 'height' => $file->getHeight(), 'size' => $file->getSize(), - 'userhidden' => (bool)$file->isDeleted( Revision::DELETED_USER ), - 'commenthidden' => (bool)$file->isDeleted( Revision::DELETED_COMMENT ), + 'userhidden' => (bool)$file->isDeleted( RevisionRecord::DELETED_USER ), + 'commenthidden' => (bool)$file->isDeleted( RevisionRecord::DELETED_COMMENT ), 'contenthidden' => (bool)$this->isDeleted(), ]; if ( !$this->isDeleted() ) { @@ -236,13 +238,13 @@ class RevDelFileItem extends RevDelItem { ), ]; } - if ( $file->userCan( Revision::DELETED_USER, $user ) ) { + if ( $file->userCan( RevisionRecord::DELETED_USER, $user ) ) { $ret += [ 'userid' => $file->user, 'user' => $file->user_text, ]; } - if ( $file->userCan( Revision::DELETED_COMMENT, $user ) ) { + if ( $file->userCan( RevisionRecord::DELETED_COMMENT, $user ) ) { $ret += [ 'comment' => $file->description, ]; diff --git a/includes/revisiondelete/RevDelList.php b/includes/revisiondelete/RevDelList.php index 221359da27..680ae8e52f 100644 --- a/includes/revisiondelete/RevDelList.php +++ b/includes/revisiondelete/RevDelList.php @@ -20,6 +20,7 @@ */ use MediaWiki\MediaWikiServices; +use MediaWiki\Storage\RevisionRecord; /** * Abstract base class for a list of deletable items. The list class @@ -195,7 +196,7 @@ abstract class RevDelList extends RevisionListBase { $status->failCount++; continue; // Cannot just "hide from Sysops" without hiding any fields - } elseif ( $newBits == Revision::DELETED_RESTRICTED ) { + } elseif ( $newBits == RevisionRecord::DELETED_RESTRICTED ) { $itemStatus->warning( 'revdelete-only-restricted', $item->formatDate(), $item->formatTime() ); $status->failCount++; diff --git a/includes/revisiondelete/RevDelLogItem.php b/includes/revisiondelete/RevDelLogItem.php index 54a715d2bc..edb86da98f 100644 --- a/includes/revisiondelete/RevDelLogItem.php +++ b/includes/revisiondelete/RevDelLogItem.php @@ -19,6 +19,8 @@ * @ingroup RevisionDelete */ +use MediaWiki\Storage\RevisionRecord; + /** * Item class for a logging table row */ @@ -44,7 +46,9 @@ class RevDelLogItem extends RevDelItem { } public function canView() { - return LogEventsList::userCan( $this->row, Revision::DELETED_RESTRICTED, $this->list->getUser() ); + return LogEventsList::userCan( + $this->row, RevisionRecord::DELETED_RESTRICTED, $this->list->getUser() + ); } public function canViewContent() { diff --git a/includes/revisiondelete/RevDelLogList.php b/includes/revisiondelete/RevDelLogList.php index b26fffd1e0..fcdcb9a592 100644 --- a/includes/revisiondelete/RevDelLogList.php +++ b/includes/revisiondelete/RevDelLogList.php @@ -19,6 +19,7 @@ * @ingroup RevisionDelete */ +use MediaWiki\Storage\RevisionRecord; use Wikimedia\Rdbms\IDatabase; /** @@ -91,7 +92,7 @@ class RevDelLogList extends RevDelList { } public function getSuppressBit() { - return Revision::DELETED_RESTRICTED; + return RevisionRecord::DELETED_RESTRICTED; } public function getLogAction() { diff --git a/includes/revisiondelete/RevDelRevisionItem.php b/includes/revisiondelete/RevDelRevisionItem.php index 6eb0b37fd1..a5859e5565 100644 --- a/includes/revisiondelete/RevDelRevisionItem.php +++ b/includes/revisiondelete/RevDelRevisionItem.php @@ -19,6 +19,8 @@ * @ingroup RevisionDelete */ +use MediaWiki\Storage\RevisionRecord; + /** * Item class for a live revision table row */ @@ -63,11 +65,15 @@ class RevDelRevisionItem extends RevDelItem { } public function canView() { - return $this->revision->userCan( Revision::DELETED_RESTRICTED, $this->list->getUser() ); + return $this->revision->userCan( + RevisionRecord::DELETED_RESTRICTED, $this->list->getUser() + ); } public function canViewContent() { - return $this->revision->userCan( Revision::DELETED_TEXT, $this->list->getUser() ); + return $this->revision->userCan( + RevisionRecord::DELETED_TEXT, $this->list->getUser() + ); } public function getBits() { @@ -108,11 +114,11 @@ class RevDelRevisionItem extends RevDelItem { } public function isDeleted() { - return $this->revision->isDeleted( Revision::DELETED_TEXT ); + return $this->revision->isDeleted( RevisionRecord::DELETED_TEXT ); } public function isHideCurrentOp( $newBits ) { - return ( $newBits & Revision::DELETED_TEXT ) + return ( $newBits & RevisionRecord::DELETED_TEXT ) && $this->list->getCurrent() == $this->getId(); } @@ -203,19 +209,19 @@ class RevDelRevisionItem extends RevDelItem { $ret = [ 'id' => $rev->getId(), 'timestamp' => wfTimestamp( TS_ISO_8601, $rev->getTimestamp() ), - 'userhidden' => (bool)$rev->isDeleted( Revision::DELETED_USER ), - 'commenthidden' => (bool)$rev->isDeleted( Revision::DELETED_COMMENT ), - 'texthidden' => (bool)$rev->isDeleted( Revision::DELETED_TEXT ), + 'userhidden' => (bool)$rev->isDeleted( RevisionRecord::DELETED_USER ), + 'commenthidden' => (bool)$rev->isDeleted( RevisionRecord::DELETED_COMMENT ), + 'texthidden' => (bool)$rev->isDeleted( RevisionRecord::DELETED_TEXT ), ]; - if ( $rev->userCan( Revision::DELETED_USER, $user ) ) { + if ( $rev->userCan( RevisionRecord::DELETED_USER, $user ) ) { $ret += [ - 'userid' => $rev->getUser( Revision::FOR_THIS_USER ), - 'user' => $rev->getUserText( Revision::FOR_THIS_USER ), + 'userid' => $rev->getUser( RevisionRecord::FOR_THIS_USER ), + 'user' => $rev->getUserText( RevisionRecord::FOR_THIS_USER ), ]; } - if ( $rev->userCan( Revision::DELETED_COMMENT, $user ) ) { + if ( $rev->userCan( RevisionRecord::DELETED_COMMENT, $user ) ) { $ret += [ - 'comment' => $rev->getComment( Revision::FOR_THIS_USER ), + 'comment' => $rev->getComment( RevisionRecord::FOR_THIS_USER ), ]; } diff --git a/includes/revisiondelete/RevDelRevisionList.php b/includes/revisiondelete/RevDelRevisionList.php index 07362c422f..0705503e9b 100644 --- a/includes/revisiondelete/RevDelRevisionList.php +++ b/includes/revisiondelete/RevDelRevisionList.php @@ -19,6 +19,7 @@ * @ingroup RevisionDelete */ +use MediaWiki\Storage\RevisionRecord; use Wikimedia\Rdbms\FakeResultWrapper; use Wikimedia\Rdbms\IDatabase; @@ -48,7 +49,7 @@ class RevDelRevisionList extends RevDelList { } public static function getRevdelConstant() { - return Revision::DELETED_TEXT; + return RevisionRecord::DELETED_TEXT; } public static function suggestTarget( $target, array $ids ) { @@ -167,7 +168,7 @@ class RevDelRevisionList extends RevDelList { } public function getSuppressBit() { - return Revision::DELETED_RESTRICTED; + return RevisionRecord::DELETED_RESTRICTED; } public function doPreCommitUpdates() { diff --git a/includes/revisiondelete/RevisionDeleteUser.php b/includes/revisiondelete/RevisionDeleteUser.php index f7f7e8956a..5644b95814 100644 --- a/includes/revisiondelete/RevisionDeleteUser.php +++ b/includes/revisiondelete/RevisionDeleteUser.php @@ -21,6 +21,7 @@ * @ingroup RevisionDelete */ +use MediaWiki\Storage\RevisionRecord; use Wikimedia\Rdbms\IDatabase; /** @@ -52,13 +53,13 @@ class RevisionDeleteUser { $dbw = wfGetDB( DB_MASTER ); } - # To suppress, we OR the current bitfields with Revision::DELETED_USER + # To suppress, we OR the current bitfields with RevisionRecord::DELETED_USER # to put a 1 in the username *_deleted bit. To unsuppress we AND the - # current bitfields with the inverse of Revision::DELETED_USER. The + # current bitfields with the inverse of RevisionRecord::DELETED_USER. The # username bit is made to 0 (x & 0 = 0), while others are unchanged (x & 1 = x). # The same goes for the sysop-restricted *_deleted bit. - $delUser = Revision::DELETED_USER | Revision::DELETED_RESTRICTED; - $delAction = LogPage::DELETED_ACTION | Revision::DELETED_RESTRICTED; + $delUser = RevisionRecord::DELETED_USER | RevisionRecord::DELETED_RESTRICTED; + $delAction = LogPage::DELETED_ACTION | RevisionRecord::DELETED_RESTRICTED; if ( $op === '&' ) { $delUser = $dbw->bitNot( $delUser ); $delAction = $dbw->bitNot( $delAction ); diff --git a/includes/revisiondelete/RevisionDeleter.php b/includes/revisiondelete/RevisionDeleter.php index 7b2147a5e5..3ab96cb0c6 100644 --- a/includes/revisiondelete/RevisionDeleter.php +++ b/includes/revisiondelete/RevisionDeleter.php @@ -21,6 +21,8 @@ * @ingroup RevisionDelete */ +use MediaWiki\Storage\RevisionRecord; + /** * General controller for RevDel, used by both SpecialRevisiondelete and * ApiRevisionDelete. @@ -129,14 +131,14 @@ class RevisionDeleter { $ret = [ 0 => [], 1 => [], 2 => [] ]; // Build bitfield changes in language self::checkItem( 'revdelete-content', - Revision::DELETED_TEXT, $diff, $n, $ret ); + RevisionRecord::DELETED_TEXT, $diff, $n, $ret ); self::checkItem( 'revdelete-summary', - Revision::DELETED_COMMENT, $diff, $n, $ret ); + RevisionRecord::DELETED_COMMENT, $diff, $n, $ret ); self::checkItem( 'revdelete-uname', - Revision::DELETED_USER, $diff, $n, $ret ); + RevisionRecord::DELETED_USER, $diff, $n, $ret ); // Restriction application to sysops - if ( $diff & Revision::DELETED_RESTRICTED ) { - if ( $n & Revision::DELETED_RESTRICTED ) { + if ( $diff & RevisionRecord::DELETED_RESTRICTED ) { + if ( $n & RevisionRecord::DELETED_RESTRICTED ) { $ret[2][] = 'revdelete-restricted'; } else { $ret[2][] = 'revdelete-unrestricted'; diff --git a/includes/revisionlist/RevisionItem.php b/includes/revisionlist/RevisionItem.php index faf8d82e79..bf90c068b6 100644 --- a/includes/revisionlist/RevisionItem.php +++ b/includes/revisionlist/RevisionItem.php @@ -20,6 +20,8 @@ * @file */ +use MediaWiki\Storage\RevisionRecord; + /** * Item class for a live revision table row */ @@ -53,15 +55,19 @@ class RevisionItem extends RevisionItemBase { } public function canView() { - return $this->revision->userCan( Revision::DELETED_RESTRICTED, $this->context->getUser() ); + return $this->revision->userCan( + RevisionRecord::DELETED_RESTRICTED, $this->context->getUser() + ); } public function canViewContent() { - return $this->revision->userCan( Revision::DELETED_TEXT, $this->context->getUser() ); + return $this->revision->userCan( + RevisionRecord::DELETED_TEXT, $this->context->getUser() + ); } public function isDeleted() { - return $this->revision->isDeleted( Revision::DELETED_TEXT ); + return $this->revision->isDeleted( RevisionRecord::DELETED_TEXT ); } /** diff --git a/includes/search/AugmentPageProps.php b/includes/search/AugmentPageProps.php index 29bd463d23..ce3fc26a24 100644 --- a/includes/search/AugmentPageProps.php +++ b/includes/search/AugmentPageProps.php @@ -13,7 +13,7 @@ class AugmentPageProps implements ResultSetAugmentor { $this->propnames = $propnames; } - public function augmentAll( SearchResultSet $resultSet ) { + public function augmentAll( ISearchResultSet $resultSet ) { $titles = $resultSet->extractTitles(); return PageProps::getInstance()->getProperties( $titles, $this->propnames ); } diff --git a/includes/search/ISearchResultSet.php b/includes/search/ISearchResultSet.php new file mode 100644 index 0000000000..1b30f5ae14 --- /dev/null +++ b/includes/search/ISearchResultSet.php @@ -0,0 +1,142 @@ + data + */ + public function setAugmentedData( $name, $data ); + + /** + * Returns extra data for specific result and store it in SearchResult object. + * @param SearchResult $result + */ + public function augmentResult( SearchResult $result ); + + /** + * @return int|null The offset the current page starts at. Typically + * this should be null to allow the UI to decide on its own, but in + * special cases like interleaved AB tests specifying explicitly is + * necessary. + */ + public function getOffset(); +} diff --git a/includes/search/PaginatingSearchEngine.php b/includes/search/PaginatingSearchEngine.php index 97ef2d57c5..f132e13c44 100644 --- a/includes/search/PaginatingSearchEngine.php +++ b/includes/search/PaginatingSearchEngine.php @@ -2,7 +2,7 @@ /** * Marker class for search engines that can handle their own pagination, by - * reporting in their SearchResultSet when a next page is available. This + * reporting in their ISearchResultSet when a next page is available. This * only applies to search{Title,Text} and not to completion search. * * SearchEngine implementations not implementing this interface will have diff --git a/includes/search/PerRowAugmentor.php b/includes/search/PerRowAugmentor.php index a3979f7b71..6430a8a108 100644 --- a/includes/search/PerRowAugmentor.php +++ b/includes/search/PerRowAugmentor.php @@ -20,10 +20,10 @@ class PerRowAugmentor implements ResultSetAugmentor { /** * Produce data to augment search result set. - * @param SearchResultSet $resultSet + * @param ISearchResultSet $resultSet * @return array Data for all results */ - public function augmentAll( SearchResultSet $resultSet ) { + public function augmentAll( ISearchResultSet $resultSet ) { $data = []; foreach ( $resultSet->extractResults() as $result ) { $id = $result->getTitle()->getArticleID(); diff --git a/includes/search/PrefixSearch.php b/includes/search/PrefixSearch.php index 3b7a0a9f99..3fff6c1b60 100644 --- a/includes/search/PrefixSearch.php +++ b/includes/search/PrefixSearch.php @@ -30,22 +30,6 @@ use MediaWiki\MediaWikiServices; * @ingroup Search */ abstract class PrefixSearch { - /** - * Do a prefix search of titles and return a list of matching page names. - * @deprecated Since 1.23, use TitlePrefixSearch or StringPrefixSearch classes - * - * @param string $search - * @param int $limit - * @param array $namespaces Used if query is not explicitly prefixed - * @param int $offset How many results to offset from the beginning - * @return array Array of strings - */ - public static function titleSearch( $search, $limit, $namespaces = [], $offset = 0 ) { - wfDeprecated( __METHOD__, '1.23' ); - $prefixSearch = new StringPrefixSearch; - return $prefixSearch->search( $search, $limit, $namespaces, $offset ); - } - /** * Do a prefix search of titles and return a list of matching page names. * diff --git a/includes/search/ResultSetAugmentor.php b/includes/search/ResultSetAugmentor.php index e2d79a9c4e..aabdde6611 100644 --- a/includes/search/ResultSetAugmentor.php +++ b/includes/search/ResultSetAugmentor.php @@ -6,8 +6,8 @@ interface ResultSetAugmentor { /** * Produce data to augment search result set. - * @param SearchResultSet $resultSet + * @param ISearchResultSet $resultSet * @return array Data for all results */ - public function augmentAll( SearchResultSet $resultSet ); + public function augmentAll( ISearchResultSet $resultSet ); } diff --git a/includes/search/SearchDatabase.php b/includes/search/SearchDatabase.php index 230cdedd71..0c5d4da58f 100644 --- a/includes/search/SearchDatabase.php +++ b/includes/search/SearchDatabase.php @@ -22,6 +22,7 @@ */ use Wikimedia\Rdbms\IDatabase; +use Wikimedia\Rdbms\ILoadBalancer; /** * Base search engine base class for database-backed searches @@ -29,21 +30,28 @@ use Wikimedia\Rdbms\IDatabase; * @since 1.23 */ abstract class SearchDatabase extends SearchEngine { + /** @var ILoadBalancer */ + protected $lb; + /** @var IDatabase (backwards compatibility) */ + protected $db; + /** - * @var IDatabase Replica database from which to read results + * @var string[] search terms */ - protected $db; + protected $searchTerms = []; /** - * @param IDatabase|null $db The database to search from + * @param ILoadBalancer $lb The load balancer for the DB cluster to search on */ - public function __construct( IDatabase $db = null ) { - $this->db = $db ?: wfGetDB( DB_REPLICA ); + public function __construct( ILoadBalancer $lb ) { + $this->lb = $lb; + // @TODO: remove this deprecated field in 1.35 + $this->db = $lb->getLazyConnectionRef( DB_REPLICA ); // b/c } /** * @param string $term - * @return SearchResultSet|Status|null + * @return ISearchResultSet|Status|null */ final public function doSearchText( $term ) { return $this->doSearchTextInDB( $this->extractNamespacePrefix( $term ) ); @@ -59,7 +67,7 @@ abstract class SearchDatabase extends SearchEngine { /** * @param string $term - * @return SearchResultSet|null + * @return ISearchResultSet|null */ final public function doSearchTitle( $term ) { return $this->doSearchTitleInDB( $this->extractNamespacePrefix( $term ) ); diff --git a/includes/search/SearchEngine.php b/includes/search/SearchEngine.php index 9771e8881e..32b0f0694d 100644 --- a/includes/search/SearchEngine.php +++ b/includes/search/SearchEngine.php @@ -46,7 +46,10 @@ abstract class SearchEngine { /** @var int */ protected $offset = 0; - /** @var array|string */ + /** + * @var string[] + * @deprecated since 1.34 + */ protected $searchTerms = []; /** @var bool */ @@ -76,7 +79,7 @@ abstract class SearchEngine { * be converted to final in 1.34. Override self::doSearchText(). * * @param string $term Raw search term - * @return SearchResultSet|Status|null + * @return ISearchResultSet|Status|null */ public function searchText( $term ) { return $this->maybePaginate( function () use ( $term ) { @@ -88,7 +91,7 @@ abstract class SearchEngine { * Perform a full text search query and return a result set. * * @param string $term Raw search term - * @return SearchResultSet|Status|null + * @return ISearchResultSet|Status|null * @since 1.32 */ protected function doSearchText( $term ) { @@ -106,7 +109,7 @@ abstract class SearchEngine { * be converted to final in 1.34. Override self::doSearchArchiveTitle(). * * @param string $term Raw search term - * @return Status + * @return Status * @since 1.29 */ public function searchArchiveTitle( $term ) { @@ -117,7 +120,7 @@ abstract class SearchEngine { * Perform a title search in the article archive. * * @param string $term Raw search term - * @return Status + * @return Status * @since 1.32 */ protected function doSearchArchiveTitle( $term ) { @@ -133,7 +136,7 @@ abstract class SearchEngine { * be converted to final in 1.34. Override self::doSearchTitle(). * * @param string $term Raw search term - * @return SearchResultSet|null + * @return ISearchResultSet|null */ public function searchTitle( $term ) { return $this->maybePaginate( function () use ( $term ) { @@ -145,7 +148,7 @@ abstract class SearchEngine { * Perform a title-only search query and return a result set. * * @param string $term Raw search term - * @return SearchResultSet|null + * @return ISearchResultSet|null * @since 1.32 */ protected function doSearchTitle( $term ) { @@ -158,7 +161,7 @@ abstract class SearchEngine { * explicitly implement their own pagination. * * @param Closure $fn Takes no arguments - * @return SearchResultSet|Status|null Result of calling $fn + * @return ISearchResultSet|Status|null Result of calling $fn */ private function maybePaginate( Closure $fn ) { if ( $this instanceof PaginatingSearchEngine ) { @@ -172,10 +175,10 @@ abstract class SearchEngine { } $resultSet = null; - if ( $resultSetOrStatus instanceof SearchResultSet ) { + if ( $resultSetOrStatus instanceof ISearchResultSet ) { $resultSet = $resultSetOrStatus; } elseif ( $resultSetOrStatus instanceof Status && - $resultSetOrStatus->getValue() instanceof SearchResultSet + $resultSetOrStatus->getValue() instanceof ISearchResultSet ) { $resultSet = $resultSetOrStatus->getValue(); } @@ -781,9 +784,9 @@ abstract class SearchEngine { /** * Augment search results with extra data. * - * @param SearchResultSet $resultSet + * @param ISearchResultSet $resultSet */ - public function augmentSearchResults( SearchResultSet $resultSet ) { + public function augmentSearchResults( ISearchResultSet $resultSet ) { $setAugmentors = []; $rowAugmentors = []; Hooks::run( "SearchResultsAugment", [ &$setAugmentors, &$rowAugmentors ] ); diff --git a/includes/search/SearchEngineFactory.php b/includes/search/SearchEngineFactory.php index ecb6f43e64..6a69cd4ee9 100644 --- a/includes/search/SearchEngineFactory.php +++ b/includes/search/SearchEngineFactory.php @@ -1,6 +1,8 @@ config->getSearchType(); + $alternativesClasses = $this->config->getSearchTypes(); - $configType = $this->config->getSearchType(); - $alternatives = $this->config->getSearchTypes(); - - if ( $type && in_array( $type, $alternatives ) ) { + $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); + if ( $type !== null && in_array( $type, $alternativesClasses ) ) { $class = $type; - } elseif ( $configType !== null ) { - $class = $configType; + } elseif ( $configuredClass !== null ) { + $class = $configuredClass; } else { - $dbr = wfGetDB( DB_REPLICA ); - $class = self::getSearchEngineClass( $dbr ); + $class = self::getSearchEngineClass( $lb ); } - $search = new $class( $dbr ); - return $search; + if ( is_subclass_of( $class, SearchDatabase::class ) ) { + return new $class( $lb ); + } else { + return new $class(); + } } /** - * @param IDatabase $db + * @param IDatabase|ILoadBalancer $dbOrLb * @return string SearchEngine subclass name * @since 1.28 */ - public static function getSearchEngineClass( IDatabase $db ) { - switch ( $db->getType() ) { + public static function getSearchEngineClass( $dbOrLb ) { + $type = ( $dbOrLb instanceof IDatabase ) + ? $dbOrLb->getType() + : $dbOrLb->getServerType( $dbOrLb->getWriterIndex() ); + + switch ( $type ) { case 'sqlite': return SearchSqlite::class; case 'mysql': diff --git a/includes/search/SearchHighlighter.php b/includes/search/SearchHighlighter.php index 469502fd43..6c01f799d5 100644 --- a/includes/search/SearchHighlighter.php +++ b/includes/search/SearchHighlighter.php @@ -44,7 +44,7 @@ class SearchHighlighter { * Wikitext highlighting when $wgAdvancedSearchHighlighting = true * * @param string $text - * @param array $terms Terms to highlight (not html escaped but + * @param string[] $terms Terms to highlight (not html escaped but * regex escaped via SearchDatabase::regexTerm()) * @param int $contextlines * @param int $contextchars @@ -502,7 +502,7 @@ class SearchHighlighter { * Used when $wgAdvancedSearchHighlighting is false. * * @param string $text - * @param array $terms Escaped for regex by SearchDatabase::regexTerm() + * @param string[] $terms Escaped for regex by SearchDatabase::regexTerm() * @param int $contextlines * @param int $contextchars * @return string diff --git a/includes/search/SearchMssql.php b/includes/search/SearchMssql.php index 0e85f9df2f..6a23bb344f 100644 --- a/includes/search/SearchMssql.php +++ b/includes/search/SearchMssql.php @@ -36,7 +36,9 @@ class SearchMssql extends SearchDatabase { * @return SqlSearchResultSet */ protected function doSearchTextInDB( $term ) { - $resultSet = $this->db->query( $this->getQuery( $this->filter( $term ), true ) ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $resultSet = $dbr->query( $this->getQuery( $this->filter( $term ), true ) ); + return new SqlSearchResultSet( $resultSet, $this->searchTerms ); } @@ -47,7 +49,9 @@ class SearchMssql extends SearchDatabase { * @return SqlSearchResultSet */ protected function doSearchTitleInDB( $term ) { - $resultSet = $this->db->query( $this->getQuery( $this->filter( $term ), false ) ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $resultSet = $dbr->query( $this->getQuery( $this->filter( $term ), false ) ); + return new SqlSearchResultSet( $resultSet, $this->searchTerms ); } @@ -72,7 +76,9 @@ class SearchMssql extends SearchDatabase { * @return string */ private function queryLimit( $sql ) { - return $this->db->limitResult( $sql, $this->limit, $this->offset ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + + return $dbr->limitResult( $sql, $this->limit, $this->offset ); } /** @@ -120,8 +126,9 @@ class SearchMssql extends SearchDatabase { */ private function queryMain( $filteredTerm, $fulltext ) { $match = $this->parseQuery( $filteredTerm, $fulltext ); - $page = $this->db->tableName( 'page' ); - $searchindex = $this->db->tableName( 'searchindex' ); + $dbr = $this->lb->getMaintenanceConnectionRef( DB_REPLICA ); + $page = $dbr->tableName( 'page' ); + $searchindex = $dbr->tableName( 'searchindex' ); return 'SELECT page_id, page_namespace, page_title, ftindex.[RANK]' . "FROM $page,FREETEXTTABLE($searchindex , $match, LANGUAGE 'English') as ftindex " . @@ -159,8 +166,10 @@ class SearchMssql extends SearchDatabase { } } - $searchon = $this->db->addQuotes( implode( ',', $q ) ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $searchon = $dbr->addQuotes( implode( ',', $q ) ); $field = $this->getIndexField( $fulltext ); + return "$field, $searchon"; } @@ -179,13 +188,14 @@ class SearchMssql extends SearchDatabase { // to properly decode the stream as UTF-8. SQL doesn't support UTF8 as a data type // but the indexer will correctly handle it by this method. Since all we are doing // is passing this data to the indexer and never retrieving it via PHP, this will save space - $table = $this->db->tableName( 'searchindex' ); + $dbr = $this->lb->getMaintenanceConnectionRef( DB_MASTER ); + $table = $dbr->tableName( 'searchindex' ); $utf8bom = '0xEFBBBF'; $si_title = $utf8bom . bin2hex( $title ); $si_text = $utf8bom . bin2hex( $text ); $sql = "DELETE FROM $table WHERE si_page = $id;"; $sql .= "INSERT INTO $table (si_page, si_title, si_text) VALUES ($id, $si_title, $si_text)"; - return $this->db->query( $sql, 'SearchMssql::update' ); + return $dbr->query( $sql, 'SearchMssql::update' ); } /** @@ -197,13 +207,14 @@ class SearchMssql extends SearchDatabase { * @return bool|IResultWrapper */ function updateTitle( $id, $title ) { - $table = $this->db->tableName( 'searchindex' ); + $dbr = $this->lb->getMaintenanceConnectionRef( DB_MASTER ); + $table = $dbr->tableName( 'searchindex' ); // see update for why we are using the utf8bom $utf8bom = '0xEFBBBF'; $si_title = $utf8bom . bin2hex( $title ); $sql = "DELETE FROM $table WHERE si_page = $id;"; $sql .= "INSERT INTO $table (si_page, si_title, si_text) VALUES ($id, $si_title, 0x00)"; - return $this->db->query( $sql, 'SearchMssql::updateTitle' ); + return $dbr->query( $sql, 'SearchMssql::updateTitle' ); } } diff --git a/includes/search/SearchMySQL.php b/includes/search/SearchMySQL.php index cae342670e..4a6b93b209 100644 --- a/includes/search/SearchMySQL.php +++ b/includes/search/SearchMySQL.php @@ -124,7 +124,8 @@ class SearchMySQL extends SearchDatabase { wfDebug( __METHOD__ . ": Can't understand search query '{$filteredText}'\n" ); } - $searchon = $this->db->addQuotes( $searchon ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $searchon = $dbr->addQuotes( $searchon ); $field = $this->getIndexField( $fulltext ); return [ " MATCH($field) AGAINST($searchon IN BOOLEAN MODE) ", @@ -186,14 +187,15 @@ class SearchMySQL extends SearchDatabase { $filteredTerm = $this->filter( $term ); $query = $this->getQuery( $filteredTerm, $fulltext ); - $resultSet = $this->db->select( + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $resultSet = $dbr->select( $query['tables'], $query['fields'], $query['conds'], __METHOD__, $query['options'], $query['joins'] ); $total = null; $query = $this->getCountQuery( $filteredTerm, $fulltext ); - $totalResult = $this->db->select( + $totalResult = $dbr->select( $query['tables'], $query['fields'], $query['conds'], __METHOD__, $query['options'], $query['joins'] ); @@ -224,7 +226,8 @@ class SearchMySQL extends SearchDatabase { protected function queryFeatures( &$query ) { foreach ( $this->features as $feature => $value ) { if ( $feature === 'title-suffix-filter' && $value ) { - $query['conds'][] = 'page_title' . $this->db->buildLike( $this->db->anyString(), $value ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $query['conds'][] = 'page_title' . $dbr->buildLike( $dbr->anyString(), $value ); } } } @@ -339,7 +342,7 @@ class SearchMySQL extends SearchDatabase { * @param string $text */ function update( $id, $title, $text ) { - $dbw = wfGetDB( DB_MASTER ); + $dbw = $this->lb->getConnectionRef( DB_MASTER ); $dbw->replace( 'searchindex', [ 'si_page' ], [ @@ -357,13 +360,12 @@ class SearchMySQL extends SearchDatabase { * @param string $title */ function updateTitle( $id, $title ) { - $dbw = wfGetDB( DB_MASTER ); - + $dbw = $this->lb->getConnectionRef( DB_MASTER ); $dbw->update( 'searchindex', [ 'si_title' => $this->normalizeText( $title ) ], [ 'si_page' => $id ], - __METHOD__, - [ $dbw->lowPriorityOption() ] ); + __METHOD__ + ); } /** @@ -374,8 +376,7 @@ class SearchMySQL extends SearchDatabase { * @param string $title Title of page that was deleted */ function delete( $id, $title ) { - $dbw = wfGetDB( DB_MASTER ); - + $dbw = $this->lb->getConnectionRef( DB_MASTER ); $dbw->delete( 'searchindex', [ 'si_page' => $id ], __METHOD__ ); } @@ -441,7 +442,7 @@ class SearchMySQL extends SearchDatabase { if ( is_null( self::$mMinSearchLength ) ) { $sql = "SHOW GLOBAL VARIABLES LIKE 'ft\\_min\\_word\\_len'"; - $dbr = wfGetDB( DB_REPLICA ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); $result = $dbr->query( $sql, __METHOD__ ); $row = $result->fetchObject(); $result->free(); diff --git a/includes/search/SearchNearMatchResultSet.php b/includes/search/SearchNearMatchResultSet.php index 42bc62d670..6d25aa43d9 100644 --- a/includes/search/SearchNearMatchResultSet.php +++ b/includes/search/SearchNearMatchResultSet.php @@ -1,6 +1,6 @@ getNearMatch( $searchterm ) ); diff --git a/includes/search/SearchOracle.php b/includes/search/SearchOracle.php index 6b2b4038dc..7240e819ad 100644 --- a/includes/search/SearchOracle.php +++ b/includes/search/SearchOracle.php @@ -71,7 +71,8 @@ class SearchOracle extends SearchDatabase { return new SqlSearchResultSet( false, '' ); } - $resultSet = $this->db->query( $this->getQuery( $this->filter( $term ), true ) ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $resultSet = $dbr->query( $this->getQuery( $this->filter( $term ), true ) ); return new SqlSearchResultSet( $resultSet, $this->searchTerms ); } @@ -86,7 +87,8 @@ class SearchOracle extends SearchDatabase { return new SqlSearchResultSet( false, '' ); } - $resultSet = $this->db->query( $this->getQuery( $this->filter( $term ), false ) ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $resultSet = $dbr->query( $this->getQuery( $this->filter( $term ), false ) ); return new SqlSearchResultSet( $resultSet, $this->searchTerms ); } @@ -101,7 +103,8 @@ class SearchOracle extends SearchDatabase { if ( $this->namespaces === [] ) { $namespaces = '0'; } else { - $namespaces = $this->db->makeList( $this->namespaces ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $namespaces = $dbr->makeList( $this->namespaces ); } return 'AND page_namespace IN (' . $namespaces . ')'; } @@ -114,7 +117,9 @@ class SearchOracle extends SearchDatabase { * @return string */ private function queryLimit( $sql ) { - return $this->db->limitResult( $sql, $this->limit, $this->offset ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + + return $dbr->limitResult( $sql, $this->limit, $this->offset ); } /** @@ -160,8 +165,11 @@ class SearchOracle extends SearchDatabase { */ function queryMain( $filteredTerm, $fulltext ) { $match = $this->parseQuery( $filteredTerm, $fulltext ); - $page = $this->db->tableName( 'page' ); - $searchindex = $this->db->tableName( 'searchindex' ); + + $dbr = $this->lb->getMaintenanceConnectionRef( DB_REPLICA ); + $page = $dbr->tableName( 'page' ); + $searchindex = $dbr->tableName( 'searchindex' ); + return 'SELECT page_id, page_namespace, page_title ' . "FROM $page,$searchindex " . 'WHERE page_id=si_page AND ' . $match; @@ -208,8 +216,10 @@ class SearchOracle extends SearchDatabase { } } - $searchon = $this->db->addQuotes( ltrim( $searchon, ' &' ) ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $searchon = $dbr->addQuotes( ltrim( $searchon, ' &' ) ); $field = $this->getIndexField( $fulltext ); + return " CONTAINS($field, $searchon, 1) > 0 "; } @@ -230,7 +240,7 @@ class SearchOracle extends SearchDatabase { * @param string $text */ function update( $id, $title, $text ) { - $dbw = wfGetDB( DB_MASTER ); + $dbw = $this->lb->getMaintenanceConnectionRef( DB_MASTER ); $dbw->replace( 'searchindex', [ 'si_page' ], [ @@ -258,8 +268,7 @@ class SearchOracle extends SearchDatabase { * @param string $title */ function updateTitle( $id, $title ) { - $dbw = wfGetDB( DB_MASTER ); - + $dbw = $this->lb->getConnectionRef( DB_MASTER ); $dbw->update( 'searchindex', [ 'si_title' => $title ], [ 'si_page' => $id ], diff --git a/includes/search/SearchPostgres.php b/includes/search/SearchPostgres.php index 74ee552abb..63634cba7f 100644 --- a/includes/search/SearchPostgres.php +++ b/includes/search/SearchPostgres.php @@ -42,7 +42,8 @@ class SearchPostgres extends SearchDatabase { protected function doSearchTitleInDB( $term ) { $q = $this->searchQuery( $term, 'titlevector', 'page_title' ); $olderror = error_reporting( E_ERROR ); - $resultSet = $this->db->query( $q, 'SearchPostgres', true ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $resultSet = $dbr->query( $q, 'SearchPostgres', true ); error_reporting( $olderror ); return new SqlSearchResultSet( $resultSet, $this->searchTerms ); } @@ -50,7 +51,8 @@ class SearchPostgres extends SearchDatabase { protected function doSearchTextInDB( $term ) { $q = $this->searchQuery( $term, 'textvector', 'old_text' ); $olderror = error_reporting( E_ERROR ); - $resultSet = $this->db->query( $q, 'SearchPostgres', true ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $resultSet = $dbr->query( $q, 'SearchPostgres', true ); error_reporting( $olderror ); return new SqlSearchResultSet( $resultSet, $this->searchTerms ); } @@ -111,7 +113,8 @@ class SearchPostgres extends SearchDatabase { $searchstring = preg_replace( '/^[\'"](.*)[\'"]$/', "$1", $searchstring ); # # Quote the whole thing - $searchstring = $this->db->addQuotes( $searchstring ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $searchstring = $dbr->addQuotes( $searchstring ); wfDebug( "parseQuery returned: $searchstring \n" ); @@ -131,7 +134,8 @@ class SearchPostgres extends SearchDatabase { # # We need a separate query here so gin does not complain about empty searches $sql = "SELECT to_tsquery($searchstring)"; - $res = $this->db->query( $sql ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $res = $dbr->query( $sql ); if ( !$res ) { # # TODO: Better output (example to catch: one 'two) die( "Sorry, that was not a valid search string. Please go back and try again" ); @@ -172,14 +176,14 @@ class SearchPostgres extends SearchDatabase { if ( count( $this->namespaces ) < 1 ) { $query .= ' AND page_namespace = 0'; } else { - $namespaces = $this->db->makeList( $this->namespaces ); + $namespaces = $dbr->makeList( $this->namespaces ); $query .= " AND page_namespace IN ($namespaces)"; } } $query .= " ORDER BY score DESC, page_id DESC"; - $query .= $this->db->limitResult( '', $this->limit, $this->offset ); + $query .= $dbr->limitResult( '', $this->limit, $this->offset ); wfDebug( "searchQuery returned: $query \n" ); @@ -201,12 +205,14 @@ class SearchPostgres extends SearchDatabase { " AND s.slot_role_id = " . $slotRoleStore->getId( SlotRecord::MAIN ) . " " . " AND c.content_id = s.slot_content_id " . " ORDER BY old_rev_text_id DESC OFFSET 1)"; - $this->db->query( $sql ); + + $dbw = $this->lb->getConnectionRef( DB_MASTER ); + $dbw->query( $sql ); + return true; } function updateTitle( $id, $title ) { return true; } - } diff --git a/includes/search/SearchResult.php b/includes/search/SearchResult.php index 7e51432b71..a862e1727a 100644 --- a/includes/search/SearchResult.php +++ b/includes/search/SearchResult.php @@ -66,10 +66,10 @@ class SearchResult { * Return a new SearchResult and initializes it with a title. * * @param Title $title - * @param SearchResultSet|null $parentSet + * @param ISearchResultSet|null $parentSet * @return SearchResult */ - public static function newFromTitle( $title, SearchResultSet $parentSet = null ) { + public static function newFromTitle( $title, ISearchResultSet $parentSet = null ) { $result = new static(); $result->initFromTitle( $title ); if ( $parentSet ) { @@ -147,26 +147,11 @@ class SearchResult { } /** - * @param array $terms Terms to highlight + * @param string[] $terms Terms to highlight (this parameter is deprecated and ignored) * @return string Highlighted text snippet, null (and not '') if not supported */ - function getTextSnippet( $terms ) { - global $wgAdvancedSearchHighlighting; - $this->initText(); - - // TODO: make highliter take a content object. Make ContentHandler a factory for SearchHighliter. - list( $contextlines, $contextchars ) = $this->searchEngine->userHighlightPrefs(); - - $h = new SearchHighlighter(); - if ( count( $terms ) > 0 ) { - if ( $wgAdvancedSearchHighlighting ) { - return $h->highlightText( $this->mText, $terms, $contextlines, $contextchars ); - } else { - return $h->highlightSimple( $this->mText, $terms, $contextlines, $contextchars ); - } - } else { - return $h->highlightNone( $this->mText, $contextlines, $contextchars ); - } + function getTextSnippet( $terms = [] ) { + return ''; } /** diff --git a/includes/search/SearchResultSet.php b/includes/search/SearchResultSet.php index 3d3b4460bd..84f8bcfd54 100644 --- a/includes/search/SearchResultSet.php +++ b/includes/search/SearchResultSet.php @@ -24,19 +24,7 @@ /** * @ingroup Search */ -class SearchResultSet implements Countable, IteratorAggregate { - - /** - * Identifier for interwiki results that are displayed only together with existing main wiki - * results. - */ - const SECONDARY_RESULTS = 0; - - /** - * Identifier for interwiki results that can be displayed even if no existing main wiki results - * exist. - */ - const INLINE_RESULTS = 1; +class SearchResultSet implements ISearchResultSet { protected $containedSyntax = false; @@ -95,7 +83,8 @@ class SearchResultSet implements Countable, IteratorAggregate { * the search terms as parsed by this engine in a text extract. * STUB * - * @return array + * @return string[] + * @deprecated since 1.34 (use SqlSearchResult) */ function termMatches() { return []; @@ -178,7 +167,7 @@ class SearchResultSet implements Countable, IteratorAggregate { * Return a result set of hits on other (multiple) wikis associated with this one * * @param int $type - * @return SearchResultSet[] + * @return ISearchResultSet[] */ function getInterwikiResults( $type = self::SECONDARY_RESULTS ) { return null; @@ -233,9 +222,9 @@ class SearchResultSet implements Countable, IteratorAggregate { /** * Frees the result set, if applicable. + * @deprecated noop since 1.34 */ function free() { - // ... } /** diff --git a/includes/search/SearchSqlite.php b/includes/search/SearchSqlite.php index c30479766e..dedcdff43c 100644 --- a/includes/search/SearchSqlite.php +++ b/includes/search/SearchSqlite.php @@ -22,6 +22,7 @@ */ use MediaWiki\MediaWikiServices; +use Wikimedia\Rdbms\DatabaseSqlite; /** * Search engine hook for SQLite @@ -32,8 +33,15 @@ class SearchSqlite extends SearchDatabase { * Whether fulltext search is supported by current schema * @return bool */ - function fulltextSearchSupported() { - return $this->db->checkForEnabledSearch(); + private function fulltextSearchSupported() { + // Avoid getConnectionRef() in order to get DatabaseSqlite specifically + /** @var DatabaseSqlite $dbr */ + $dbr = $this->lb->getConnection( DB_REPLICA ); + try { + return $dbr->checkForEnabledSearch(); + } finally { + $this->lb->reuseConnection( $dbr ); + } } /** @@ -120,8 +128,10 @@ class SearchSqlite extends SearchDatabase { wfDebug( __METHOD__ . ": Can't understand search query '{$filteredText}'\n" ); } - $searchon = $this->db->addQuotes( $searchon ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $searchon = $dbr->addQuotes( $searchon ); $field = $this->getIndexField( $fulltext ); + return " $field MATCH $searchon "; } @@ -178,10 +188,11 @@ class SearchSqlite extends SearchDatabase { $filteredTerm = $this->filter( MediaWikiServices::getInstance()->getContentLanguage()->lc( $term ) ); - $resultSet = $this->db->query( $this->getQuery( $filteredTerm, $fulltext ) ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $resultSet = $dbr->query( $this->getQuery( $filteredTerm, $fulltext ) ); $total = null; - $totalResult = $this->db->query( $this->getCountQuery( $filteredTerm, $fulltext ) ); + $totalResult = $dbr->query( $this->getCountQuery( $filteredTerm, $fulltext ) ); $row = $totalResult->fetchObject(); if ( $row ) { $total = intval( $row->c ); @@ -202,7 +213,8 @@ class SearchSqlite extends SearchDatabase { if ( $this->namespaces === [] ) { $namespaces = '0'; } else { - $namespaces = $this->db->makeList( $this->namespaces ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $namespaces = $dbr->makeList( $this->namespaces ); } return 'AND page_namespace IN (' . $namespaces . ')'; } @@ -213,7 +225,9 @@ class SearchSqlite extends SearchDatabase { * @return string */ private function limitResult( $sql ) { - return $this->db->limitResult( $sql, $this->limit, $this->offset ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + + return $dbr->limitResult( $sql, $this->limit, $this->offset ); } /** @@ -248,8 +262,9 @@ class SearchSqlite extends SearchDatabase { */ private function queryMain( $filteredTerm, $fulltext ) { $match = $this->parseQuery( $filteredTerm, $fulltext ); - $page = $this->db->tableName( 'page' ); - $searchindex = $this->db->tableName( 'searchindex' ); + $dbr = $this->lb->getMaintenanceConnectionRef( DB_REPLICA ); + $page = $dbr->tableName( 'page' ); + $searchindex = $dbr->tableName( 'searchindex' ); return "SELECT $searchindex.rowid, page_namespace, page_title " . "FROM $page,$searchindex " . "WHERE page_id=$searchindex.rowid AND $match"; @@ -257,8 +272,9 @@ class SearchSqlite extends SearchDatabase { private function getCountQuery( $filteredTerm, $fulltext ) { $match = $this->parseQuery( $filteredTerm, $fulltext ); - $page = $this->db->tableName( 'page' ); - $searchindex = $this->db->tableName( 'searchindex' ); + $dbr = $this->lb->getMaintenanceConnectionRef( DB_REPLICA ); + $page = $dbr->tableName( 'page' ); + $searchindex = $dbr->tableName( 'searchindex' ); return "SELECT COUNT(*) AS c " . "FROM $page,$searchindex " . "WHERE page_id=$searchindex.rowid AND $match " . @@ -273,16 +289,14 @@ class SearchSqlite extends SearchDatabase { * @param string $title * @param string $text */ - function update( $id, $title, $text ) { + public function update( $id, $title, $text ) { if ( !$this->fulltextSearchSupported() ) { return; } // @todo find a method to do it in a single request, // couldn't do it so far due to typelessness of FTS3 tables. - $dbw = wfGetDB( DB_MASTER ); - + $dbw = $this->lb->getConnectionRef( DB_MASTER ); $dbw->delete( 'searchindex', [ 'rowid' => $id ], __METHOD__ ); - $dbw->insert( 'searchindex', [ 'rowid' => $id, @@ -298,12 +312,12 @@ class SearchSqlite extends SearchDatabase { * @param int $id * @param string $title */ - function updateTitle( $id, $title ) { + public function updateTitle( $id, $title ) { if ( !$this->fulltextSearchSupported() ) { return; } - $dbw = wfGetDB( DB_MASTER ); + $dbw = $this->lb->getConnectionRef( DB_MASTER ); $dbw->update( 'searchindex', [ 'si_title' => $title ], [ 'rowid' => $id ], diff --git a/includes/search/SqlSearchResult.php b/includes/search/SqlSearchResult.php new file mode 100644 index 0000000000..25e87e70ff --- /dev/null +++ b/includes/search/SqlSearchResult.php @@ -0,0 +1,69 @@ +initFromTitle( $title ); + $this->terms = $terms; + } + + /** + * return string[] + */ + public function getTermMatches(): array { + return $this->terms; + } + + /** + * @param array $terms Terms to highlight (this parameter is deprecated) + * @return string Highlighted text snippet, null (and not '') if not supported + */ + function getTextSnippet( $terms = [] ) { + global $wgAdvancedSearchHighlighting; + $this->initText(); + + // TODO: make highliter take a content object. Make ContentHandler a factory for SearchHighliter. + list( $contextlines, $contextchars ) = $this->searchEngine->userHighlightPrefs(); + + $h = new SearchHighlighter(); + if ( count( $this->terms ) > 0 ) { + if ( $wgAdvancedSearchHighlighting ) { + return $h->highlightText( $this->mText, $this->terms, $contextlines, $contextchars ); + } else { + return $h->highlightSimple( $this->mText, $this->terms, $contextlines, $contextchars ); + } + } else { + return $h->highlightNone( $this->mText, $contextlines, $contextchars ); + } + } + +} diff --git a/includes/search/SqlSearchResultSet.php b/includes/search/SqlSearchResultSet.php index 022dc0a643..f5d795f4b6 100644 --- a/includes/search/SqlSearchResultSet.php +++ b/includes/search/SqlSearchResultSet.php @@ -1,25 +1,37 @@ resultSet = $resultSet; $this->terms = $terms; $this->totalHits = $total; } + /** + * @return string[] + * @deprecated since 1.34 + */ function termMatches() { return $this->terms; } @@ -40,23 +52,20 @@ class SqlSearchResultSet extends SearchResultSet { if ( $this->results === null ) { $this->results = []; $this->resultSet->rewind(); + $terms = \MediaWiki\MediaWikiServices::getInstance()->getContentLanguage() + ->convertForSearchResult( $this->terms ); while ( ( $row = $this->resultSet->fetchObject() ) !== false ) { - $this->results[] = SearchResult::newFromTitle( - Title::makeTitle( $row->page_namespace, $row->page_title ), $this + $result = new SqlSearchResult( + Title::makeTitle( $row->page_namespace, $row->page_title ), + $terms ); + $this->augmentResult( $result ); + $this->results[] = $result; } } return $this->results; } - function free() { - if ( $this->resultSet === false ) { - return false; - } - - $this->resultSet->free(); - } - function getTotalHits() { if ( !is_null( $this->totalHits ) ) { return $this->totalHits; diff --git a/includes/session/PHPSessionHandler.php b/includes/session/PHPSessionHandler.php index f14e0eb740..64c2b84d8d 100644 --- a/includes/session/PHPSessionHandler.php +++ b/includes/session/PHPSessionHandler.php @@ -25,6 +25,7 @@ namespace MediaWiki\Session; use Psr\Log\LoggerInterface; use BagOStuff; +use Psr\Log\NullLogger; /** * Adapter for PHP's session handling @@ -41,7 +42,7 @@ class PHPSessionHandler implements \SessionHandlerInterface { /** @var bool */ protected $warn = true; - /** @var SessionManager|null */ + /** @var SessionManagerInterface|null */ protected $manager; /** @var BagOStuff|null */ @@ -53,7 +54,7 @@ class PHPSessionHandler implements \SessionHandlerInterface { /** @var array Track original session fields for later modification check */ protected $sessionFieldCache = []; - protected function __construct( SessionManager $manager ) { + protected function __construct( SessionManagerInterface $manager ) { $this->setEnableFlags( \RequestContext::getMain()->getConfig()->get( 'PHPSessionHandling' ) ); @@ -105,9 +106,9 @@ class PHPSessionHandler implements \SessionHandlerInterface { /** * Install a session handler for the current web request - * @param SessionManager $manager + * @param SessionManagerInterface $manager */ - public static function install( SessionManager $manager ) { + public static function install( SessionManagerInterface $manager ) { if ( self::$instance ) { $manager->setupPHPSessionHandler( self::$instance ); return; @@ -151,12 +152,12 @@ class PHPSessionHandler implements \SessionHandlerInterface { /** * Set the manager, store, and logger * @private Use self::install(). - * @param SessionManager $manager + * @param SessionManagerInterface $manager * @param BagOStuff $store * @param LoggerInterface $logger */ public function setManager( - SessionManager $manager, BagOStuff $store, LoggerInterface $logger + SessionManagerInterface $manager, BagOStuff $store, LoggerInterface $logger ) { if ( $this->manager !== $manager ) { // Close any existing session before we change stores @@ -299,7 +300,7 @@ class PHPSessionHandler implements \SessionHandlerInterface { } // Anything deleted in $_SESSION and unchanged in Session should be deleted too // (but not if $_SESSION can't represent it at all) - \Wikimedia\PhpSessionSerializer::setLogger( new \Psr\Log\NullLogger() ); + \Wikimedia\PhpSessionSerializer::setLogger( new NullLogger() ); foreach ( $cache as $key => $value ) { if ( !array_key_exists( $key, $data ) && $session->exists( $key ) && \Wikimedia\PhpSessionSerializer::encode( [ $key => true ] ) diff --git a/includes/session/SessionManager.php b/includes/session/SessionManager.php index 98c04995a3..3810565bcb 100644 --- a/includes/session/SessionManager.php +++ b/includes/session/SessionManager.php @@ -329,12 +329,9 @@ final class SessionManager implements SessionManagerInterface { $headers = []; foreach ( $this->getProviders() as $provider ) { foreach ( $provider->getVaryHeaders() as $header => $options ) { - if ( !isset( $headers[$header] ) ) { - $headers[$header] = []; - } - if ( is_array( $options ) ) { - $headers[$header] = array_unique( array_merge( $headers[$header], $options ) ); - } + # Note that the $options value returned has been deprecated + # and is ignored. + $headers[$header] = null; } } $this->varyHeaders = $headers; diff --git a/includes/session/SessionManagerInterface.php b/includes/session/SessionManagerInterface.php index c6990fefe7..7c05cfc6a6 100644 --- a/includes/session/SessionManagerInterface.php +++ b/includes/session/SessionManagerInterface.php @@ -96,6 +96,9 @@ interface SessionManagerInterface extends LoggerAwareInterface { * } * @endcode * + * Note that the $options argument to OutputPage::addVaryHeader() has + * been deprecated and should always be null. + * * @return array */ public function getVaryHeaders(); diff --git a/includes/session/SessionProvider.php b/includes/session/SessionProvider.php index def3bc3125..80a400b057 100644 --- a/includes/session/SessionProvider.php +++ b/includes/session/SessionProvider.php @@ -402,6 +402,9 @@ abstract class SessionProvider implements SessionProviderInterface, LoggerAwareI * } * @endcode * + * Note that the $options parameter to addVaryHeader has been deprecated + * since 1.34, and should be `null` or an empty array. + * * @protected For use by \MediaWiki\Session\SessionManager only * @return array */ diff --git a/includes/shell/Command.php b/includes/shell/Command.php index 20b94459cb..4ba78688dc 100644 --- a/includes/shell/Command.php +++ b/includes/shell/Command.php @@ -73,14 +73,14 @@ class Command { private $cgroup = false; /** - * bitfield with restrictions + * Bitfield with restrictions * * @var int */ protected $restrictions = 0; /** - * Constructor. Don't call directly, instead use Shell::command() + * Don't call directly, instead use Shell::command() * * @throws ShellDisabledError */ @@ -93,7 +93,7 @@ class Command { } /** - * Destructor. Makes sure programmer didn't forget to execute the command after all + * Makes sure the programmer didn't forget to execute the command after all */ public function __destruct() { if ( !$this->everExecuted ) { @@ -446,8 +446,9 @@ class Command { // stream_select parameter names are from the POV of us being able to do the operation; // proc_open desriptor types are from the POV of the process doing it. // So $writePipes is passed as the $read parameter and $readPipes as $write. - // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged - $numReadyPipes = @stream_select( $writePipes, $readPipes, $emptyArray, $timeout ); + AtEase::suppressWarnings(); + $numReadyPipes = stream_select( $writePipes, $readPipes, $emptyArray, $timeout ); + AtEase::restoreWarnings(); if ( $numReadyPipes === false ) { $error = error_get_last(); if ( strncmp( $error['message'], $eintrMessage, strlen( $eintrMessage ) ) == 0 ) { diff --git a/includes/site/DBSiteStore.php b/includes/site/DBSiteStore.php index b2403ce16b..6076aba866 100644 --- a/includes/site/DBSiteStore.php +++ b/includes/site/DBSiteStore.php @@ -1,6 +1,6 @@ dbLoadBalancer = $dbLoadBalancer; } @@ -75,7 +75,7 @@ class DBSiteStore implements SiteStore { protected function loadSites() { $this->sites = new SiteList(); - $dbr = $this->dbLoadBalancer->getConnection( DB_REPLICA ); + $dbr = $this->dbLoadBalancer->getConnectionRef( DB_REPLICA ); $res = $dbr->select( 'sites', @@ -178,7 +178,7 @@ class DBSiteStore implements SiteStore { return true; } - $dbw = $this->dbLoadBalancer->getConnection( DB_MASTER ); + $dbw = $this->dbLoadBalancer->getConnectionRef( DB_MASTER ); $dbw->startAtomic( __METHOD__ ); @@ -269,7 +269,7 @@ class DBSiteStore implements SiteStore { * @return bool Success */ public function clear() { - $dbw = $this->dbLoadBalancer->getConnection( DB_MASTER ); + $dbw = $this->dbLoadBalancer->getConnectionRef( DB_MASTER ); $dbw->startAtomic( __METHOD__ ); $ok = $dbw->delete( 'sites', '*', __METHOD__ ); diff --git a/includes/skins/BaseTemplate.php b/includes/skins/BaseTemplate.php index 6d108e8ec4..cd79259e5e 100644 --- a/includes/skins/BaseTemplate.php +++ b/includes/skins/BaseTemplate.php @@ -85,7 +85,7 @@ abstract class BaseTemplate extends QuickTemplate { $toolbox['feeds']['links'][$key]['class'] = 'feedlink'; } } - foreach ( [ 'contributions', 'log', 'blockip', 'emailuser', + foreach ( [ 'contributions', 'log', 'blockip', 'emailuser', 'mute', 'userrights', 'upload', 'specialpages' ] as $special ) { if ( isset( $this->data['nav_urls'][$special] ) && $this->data['nav_urls'][$special] ) { diff --git a/includes/skins/Skin.php b/includes/skins/Skin.php index f45596f3fd..918c761bca 100644 --- a/includes/skins/Skin.php +++ b/includes/skins/Skin.php @@ -389,9 +389,8 @@ abstract class Skin extends ContextSource { /** * Outputs the HTML generated by other functions. - * @param OutputPage|null $out */ - abstract function outputPage( OutputPage $out = null ); + abstract function outputPage(); /** * @param array $data @@ -814,9 +813,11 @@ abstract class Skin extends ContextSource { } /** + * @deprecated since 1.34, use getSearchLink() instead. * @return string */ function escapeSearchLink() { + wfDeprecated( __METHOD__, '1.34' ); return htmlspecialchars( $this->getSearchLink() ); } diff --git a/includes/skins/SkinFactory.php b/includes/skins/SkinFactory.php index eb71fe6f35..98d3456adc 100644 --- a/includes/skins/SkinFactory.php +++ b/includes/skins/SkinFactory.php @@ -21,8 +21,6 @@ * @file */ -use MediaWiki\MediaWikiServices; - /** * Factory class to create Skin objects * @@ -43,15 +41,6 @@ class SkinFactory { */ private $displayNames = []; - /** - * @deprecated in 1.27 - * @return SkinFactory - */ - public static function getDefaultInstance() { - wfDeprecated( __METHOD__, '1.27' ); - return MediaWikiServices::getInstance()->getSkinFactory(); - } - /** * Register a new Skin factory function. * diff --git a/includes/skins/SkinTemplate.php b/includes/skins/SkinTemplate.php index a7b7569f0e..5fd9f1fed7 100644 --- a/includes/skins/SkinTemplate.php +++ b/includes/skins/SkinTemplate.php @@ -207,21 +207,10 @@ class SkinTemplate extends Skin { } /** - * initialize various variables and generate the template - * - * @param OutputPage|null $out + * Initialize various variables and generate the template */ - function outputPage( OutputPage $out = null ) { + function outputPage() { Profiler::instance()->setTemplated( true ); - - $oldContext = null; - if ( $out !== null ) { - // Deprecated since 1.20, note added in 1.25 - wfDeprecated( __METHOD__, '1.25' ); - $oldContext = $this->getContext(); - $this->setContext( $out->getContext() ); - } - $out = $this->getOutput(); $this->initPage( $out ); @@ -231,10 +220,6 @@ class SkinTemplate extends Skin { // result may be an error $this->printOrError( $res ); - - if ( $oldContext ) { - $this->setContext( $oldContext ); - } } /** @@ -271,7 +256,7 @@ class SkinTemplate extends Skin { * @return QuickTemplate The template to be executed by outputPage */ protected function prepareQuickTemplate() { - global $wgScript, $wgStylePath, $wgMimeType, $wgJsMimeType, + global $wgScript, $wgStylePath, $wgMimeType, $wgSitename, $wgLogo, $wgMaxCredits, $wgShowCreditsIfMax, $wgArticlePath, $wgScriptPath, $wgServer; @@ -321,7 +306,6 @@ class SkinTemplate extends Skin { } $tpl->set( 'mimetype', $wgMimeType ); - $tpl->set( 'jsmimetype', $wgJsMimeType ); $tpl->set( 'charset', 'UTF-8' ); $tpl->set( 'wgScript', $wgScript ); $tpl->set( 'skinname', $this->skinname ); @@ -332,7 +316,7 @@ class SkinTemplate extends Skin { $tpl->set( 'handheld', $request->getBool( 'handheld' ) ); $tpl->set( 'loggedin', $this->loggedin ); $tpl->set( 'notspecialpage', !$title->isSpecialPage() ); - $tpl->set( 'searchaction', $this->escapeSearchLink() ); + $tpl->set( 'searchaction', $this->getSearchLink() ); $tpl->set( 'searchtitle', SpecialPage::getTitleFor( 'Search' )->getPrefixedDBkey() ); $tpl->set( 'search', trim( $request->getVal( 'search' ) ) ); $tpl->set( 'stylepath', $wgStylePath ); @@ -1293,6 +1277,7 @@ class SkinTemplate extends Skin { $nav_urls['contributions'] = false; $nav_urls['log'] = false; $nav_urls['blockip'] = false; + $nav_urls['mute'] = false; $nav_urls['emailuser'] = false; $nav_urls['userrights'] = false; @@ -1370,6 +1355,13 @@ class SkinTemplate extends Skin { } if ( !$user->isAnon() ) { + if ( $this->getUser()->isRegistered() && $this->getConfig()->get( 'EnableSpecialMute' ) ) { + $nav_urls['mute'] = [ + 'text' => $this->msg( 'mute-preferences' )->text(), + 'href' => self::makeSpecialUrlSubpage( 'Mute', $rootUser ) + ]; + } + $sur = new UserrightsPage; $sur->setContext( $this->getContext() ); $canChange = $sur->userCanChangeRights( $user ); diff --git a/includes/specialpage/AuthManagerSpecialPage.php b/includes/specialpage/AuthManagerSpecialPage.php index 101570fe89..65cd2d2e72 100644 --- a/includes/specialpage/AuthManagerSpecialPage.php +++ b/includes/specialpage/AuthManagerSpecialPage.php @@ -429,7 +429,7 @@ abstract class AuthManagerSpecialPage extends SpecialPage { // accidentally returning it so best check and fix $status = Status::wrap( $status ); } elseif ( is_string( $status ) ) { - $status = Status::newFatal( new RawMessage( '$1', $status ) ); + $status = Status::newFatal( new RawMessage( '$1', [ $status ] ) ); } elseif ( is_array( $status ) ) { if ( is_string( reset( $status ) ) ) { $status = Status::newFatal( ...$status ); diff --git a/includes/specialpage/ChangesListSpecialPage.php b/includes/specialpage/ChangesListSpecialPage.php index 3c9abb2ab5..f9b4542856 100644 --- a/includes/specialpage/ChangesListSpecialPage.php +++ b/includes/specialpage/ChangesListSpecialPage.php @@ -824,9 +824,27 @@ abstract class ChangesListSpecialPage extends SpecialPage { } } + /** + * Get essential data about getRcFiltersConfigVars() for change detection. + * + * @internal For use by Resources.php only. + * @see ResourceLoaderModule::getDefinitionSummary() and ResourceLoaderModule::getVersionHash() + * @param ResourceLoaderContext $context + * @return array + */ + public static function getRcFiltersConfigSummary( ResourceLoaderContext $context ) { + return [ + // Reduce version computation by avoiding Message parsing + 'RCFiltersChangeTags' => self::getChangeTagListSummary( $context ), + 'StructuredChangeFiltersEditWatchlistUrl' => + SpecialPage::getTitleFor( 'EditWatchlist' )->getLocalURL() + ]; + } + /** * Get config vars to export with the mediawiki.rcfilters.filters.ui module. * + * @internal For use by Resources.php only. * @param ResourceLoaderContext $context * @return array */ @@ -839,70 +857,105 @@ abstract class ChangesListSpecialPage extends SpecialPage { } /** - * Fetch the change tags list for the front end + * Get (cheap to compute) information about change tags. + * + * Returns an array of associative arrays with information about each tag: + * - name: Tag name (string) + * - labelMsg: Short description message (Message object) + * - descriptionMsg: Long description message (Message object) + * - cssClass: CSS class to use for RC entries with this tag + * - hits: Number of RC entries that have this tag * * @param ResourceLoaderContext $context - * @return array Tag data + * @return array[] Information about each tag */ - protected static function getChangeTagList( ResourceLoaderContext $context ) { - $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); - return $cache->getWithSetCallback( - $cache->makeKey( 'changeslistspecialpage-changetags', $context->getLanguage() ), - $cache::TTL_MINUTE * 10, - function () use ( $context ) { - $explicitlyDefinedTags = array_fill_keys( ChangeTags::listExplicitlyDefinedTags(), 0 ); - $softwareActivatedTags = array_fill_keys( ChangeTags::listSoftwareActivatedTags(), 0 ); - - $tagStats = ChangeTags::tagUsageStatistics(); - $tagHitCounts = array_merge( $explicitlyDefinedTags, $softwareActivatedTags, $tagStats ); - - // Sort by hits (disabled for now) - //arsort( $tagHitCounts ); - - // HACK work around ChangeTags::truncateTagDescription() requiring a RequestContext - $fakeContext = RequestContext::newExtraneousContext( Title::newFromText( 'Dwimmerlaik' ) ); - $fakeContext->setLanguage( Language::factory( $context->getLanguage() ) ); - - // Build the list and data - $result = []; - foreach ( $tagHitCounts as $tagName => $hits ) { - if ( - ( - // Only get active tags - isset( $explicitlyDefinedTags[ $tagName ] ) || - isset( $softwareActivatedTags[ $tagName ] ) - ) && - // Only get tags with more than 0 hits - $hits > 0 - ) { - $result[] = [ - 'name' => $tagName, - 'label' => Sanitizer::stripAllTags( - ChangeTags::tagDescription( $tagName, $context ) - ), - 'description' => - ChangeTags::truncateTagDescription( - $tagName, - self::TAG_DESC_CHARACTER_LIMIT, - $fakeContext - ), - 'cssClass' => Sanitizer::escapeClass( 'mw-tag-' . $tagName ), - 'hits' => $hits, - ]; - } + protected static function getChangeTagInfo( ResourceLoaderContext $context ) { + $explicitlyDefinedTags = array_fill_keys( ChangeTags::listExplicitlyDefinedTags(), 0 ); + $softwareActivatedTags = array_fill_keys( ChangeTags::listSoftwareActivatedTags(), 0 ); + + $tagStats = ChangeTags::tagUsageStatistics(); + $tagHitCounts = array_merge( $explicitlyDefinedTags, $softwareActivatedTags, $tagStats ); + + $result = []; + foreach ( $tagHitCounts as $tagName => $hits ) { + if ( + ( + // Only get active tags + isset( $explicitlyDefinedTags[ $tagName ] ) || + isset( $softwareActivatedTags[ $tagName ] ) + ) && + // Only get tags with more than 0 hits + $hits > 0 + ) { + $labelMsg = ChangeTags::tagShortDescriptionMessage( $tagName, $context ); + if ( $labelMsg === false ) { + // Tag is hidden, skip it + continue; } + $result[] = [ + 'name' => $tagName, + // 'label' and 'description' filled in by getChangeTagList() + 'labelMsg' => $labelMsg, + 'descriptionMsg' => ChangeTags::tagLongDescriptionMessage( $tagName, $context ), + 'cssClass' => Sanitizer::escapeClass( 'mw-tag-' . $tagName ), + 'hits' => $hits, + ]; + } + } + return $result; + } - // Instead of sorting by hit count (disabled, see above), sort by display name - usort( $result, function ( $a, $b ) { - return strcasecmp( $a['label'], $b['label'] ); - } ); + /** + * Get information about change tags for use in getRcFiltersConfigSummary(). + * + * This expands labelMsg and descriptionMsg to the raw values of each message, which captures + * changes in the messages but avoids the expensive step of parsing them. + * + * @param ResourceLoaderContext $context + * @return array[] Result of getChangeTagInfo(), with messages expanded to raw contents + */ + protected static function getChangeTagListSummary( ResourceLoaderContext $context ) { + $tags = self::getChangeTagInfo( $context ); + foreach ( $tags as &$tagInfo ) { + $tagInfo['labelMsg'] = $tagInfo['labelMsg']->plain(); + if ( $tagInfo['descriptionMsg'] ) { + $tagInfo['descriptionMsg'] = $tagInfo['descriptionMsg']->plain(); + } + } + return $tags; + } - return $result; - }, - [ - 'lockTSE' => 30 - ] - ); + /** + * Get information about change tags to export to JS via getRcFiltersConfigVars(). + * + * This removes labelMsg and descriptionMsg, and adds label and description, which are parsed, + * stripped and (in the case of description) truncated versions of these messages. Message + * parsing is expensive, so to detect whether the tag list has changed, use + * getChangeTagListSummary() instead. + * + * @param ResourceLoaderContext $context + * @return array[] Result of getChangeTagInfo(), with messages parsed, stripped and truncated + */ + protected static function getChangeTagList( ResourceLoaderContext $context ) { + $tags = self::getChangeTagInfo( $context ); + $language = Language::factory( $context->getLanguage() ); + foreach ( $tags as &$tagInfo ) { + $tagInfo['label'] = Sanitizer::stripAllTags( $tagInfo['labelMsg']->parse() ); + $tagInfo['description'] = $tagInfo['descriptionMsg'] ? + $language->truncateForVisual( + Sanitizer::stripAllTags( $tagInfo['descriptionMsg']->parse() ), + self::TAG_DESC_CHARACTER_LIMIT + ) : + ''; + unset( $tagInfo['labelMsg'] ); + unset( $tagInfo['descriptionMsg'] ); + } + + // Instead of sorting by hit count (disabled for now), sort by display name + usort( $tags, function ( $a, $b ) { + return strcasecmp( $a['label'], $b['label'] ); + } ); + return $tags; } /** diff --git a/includes/specialpage/QueryPage.php b/includes/specialpage/QueryPage.php index 700672f12f..eb179bf310 100644 --- a/includes/specialpage/QueryPage.php +++ b/includes/specialpage/QueryPage.php @@ -660,7 +660,7 @@ abstract class QueryPage extends SpecialPage { # an OutputPage, and let them get on with it $this->outputResults( $out, $this->getSkin(), - $dbr, # Should use a ResultWrapper for this + $dbr, # Should use IResultWrapper for this $res, min( $this->numRows, $this->limit ), # do not format the one extra row, if exist $this->offset ); @@ -738,13 +738,13 @@ abstract class QueryPage extends SpecialPage { } /** - * Creates a new LinkBatch object, adds all pages from the passed ResultWrapper (MUST include + * Creates a new LinkBatch object, adds all pages from the passed result wrapper (MUST include * title and optional the namespace field) and executes the batch. This operation will pre-cache * LinkCache information like page existence and information for stub color and redirect hints. * - * @param IResultWrapper $res The ResultWrapper object to process. Needs to include the title + * @param IResultWrapper $res The result wrapper to process. Needs to include the title * field and namespace field, if the $ns parameter isn't set. - * @param null $ns Use this namespace for the given titles in the ResultWrapper object, + * @param null $ns Use this namespace for the given titles in the result wrapper, * instead of the namespace value of $res. */ protected function executeLBFromResultWrapper( IResultWrapper $res, $ns = null ) { diff --git a/includes/specialpage/RedirectSpecialPage.php b/includes/specialpage/RedirectSpecialPage.php index c28b89ea2c..c1b21e6013 100644 --- a/includes/specialpage/RedirectSpecialPage.php +++ b/includes/specialpage/RedirectSpecialPage.php @@ -74,7 +74,7 @@ abstract class RedirectSpecialPage extends UnlistedSpecialPage { $request = $this->getRequest(); foreach ( array_merge( $this->mAllowedRedirectParams, - [ 'uselang', 'useskin', 'debug' ] // parameters which can be passed to all pages + [ 'uselang', 'useskin', 'debug', 'safemode' ] // parameters which can be passed to all pages ) as $arg ) { if ( $request->getVal( $arg, null ) !== null ) { $params[$arg] = $request->getVal( $arg ); diff --git a/includes/specialpage/SpecialPage.php b/includes/specialpage/SpecialPage.php index eba406e7de..d7e39d5129 100644 --- a/includes/specialpage/SpecialPage.php +++ b/includes/specialpage/SpecialPage.php @@ -456,10 +456,10 @@ class SpecialPage implements MessageLocalizer { * For example, if a page supports subpages "foo", "bar" and "baz" (as in Special:PageName/foo, * etc.): * - * - `prefixSearchSubpages( "ba" )` should return `array( "bar", "baz" )` - * - `prefixSearchSubpages( "f" )` should return `array( "foo" )` - * - `prefixSearchSubpages( "z" )` should return `array()` - * - `prefixSearchSubpages( "" )` should return `array( foo", "bar", "baz" )` + * - `prefixSearchSubpages( "ba" )` should return `[ "bar", "baz" ]` + * - `prefixSearchSubpages( "f" )` should return `[ "foo" ]` + * - `prefixSearchSubpages( "z" )` should return `[]` + * - `prefixSearchSubpages( "" )` should return `[ foo", "bar", "baz" ]` * * @param string $search Prefix to search for * @param int $limit Maximum number of results to return (usually 10) diff --git a/includes/specialpage/SpecialPageFactory.php b/includes/specialpage/SpecialPageFactory.php index 1053bda5c4..40172ab693 100644 --- a/includes/specialpage/SpecialPageFactory.php +++ b/includes/specialpage/SpecialPageFactory.php @@ -232,6 +232,7 @@ class SpecialPageFactory { 'EmailAuthentication', 'EnableEmail', 'EnableJavaScriptTest', + 'EnableSpecialMute', 'PageLanguageUseDB', 'SpecialPages', ]; @@ -282,9 +283,14 @@ class SpecialPageFactory { $this->list['JavaScriptTest'] = \SpecialJavaScriptTest::class; } + if ( $this->options->get( 'EnableSpecialMute' ) ) { + $this->list['Mute'] = \SpecialMute::class; + } + if ( $this->options->get( 'PageLanguageUseDB' ) ) { $this->list['PageLanguage'] = \SpecialPageLanguage::class; } + if ( $this->options->get( 'ContentHandlerUseDB' ) ) { $this->list['ChangeContentModel'] = \SpecialChangeContentModel::class; } @@ -361,7 +367,7 @@ class SpecialPageFactory { * subpage. * * @param string $alias - * @return array Array( String, String|null ), or array( null, null ) if the page is invalid + * @return array [ String, String|null ], or [ null, null ] if the page is invalid */ public function resolveAlias( $alias ) { $bits = explode( '/', $alias, 2 ); diff --git a/includes/specials/SpecialBrokenRedirects.php b/includes/specials/SpecialBrokenRedirects.php index 3e1909b836..17f89f9c4a 100644 --- a/includes/specials/SpecialBrokenRedirects.php +++ b/includes/specials/SpecialBrokenRedirects.php @@ -163,6 +163,11 @@ class BrokenRedirectsPage extends QueryPage { return $out; } + public function execute( $par ) { + $this->addHelpLink( 'Help:Redirects' ); + parent::execute( $par ); + } + /** * Cache page content model for performance * diff --git a/includes/specials/SpecialChangeCredentials.php b/includes/specials/SpecialChangeCredentials.php index 1d0ff21cf2..6841cc5950 100644 --- a/includes/specials/SpecialChangeCredentials.php +++ b/includes/specials/SpecialChangeCredentials.php @@ -49,27 +49,6 @@ class SpecialChangeCredentials extends AuthManagerSpecialPage { return $params; } - public function onAuthChangeFormFields( - array $requests, array $fieldInfo, array &$formDescriptor, $action - ) { - // This method is never called for remove actions. - - $extraFields = []; - Hooks::run( 'ChangePasswordForm', [ &$extraFields ], '1.27' ); - foreach ( $extraFields as $extra ) { - list( $name, $label, $type, $default ) = $extra; - $formDescriptor[$name] = [ - 'type' => $type, - 'name' => $name, - 'label-message' => $label, - 'default' => $default, - ]; - - } - - return parent::onAuthChangeFormFields( $requests, $fieldInfo, $formDescriptor, $action ); - } - public function execute( $subPage ) { $this->setHeaders(); $this->outputHeader(); @@ -141,9 +120,7 @@ class SpecialChangeCredentials extends AuthManagerSpecialPage { } if ( $any ) { - $this->getOutput()->addModules( [ - 'mediawiki.special.changecredentials.js' - ] ); + $this->getOutput()->addModules( 'mediawiki.misc-authed-ooui' ); } return $descriptor; diff --git a/includes/specials/SpecialChangeEmail.php b/includes/specials/SpecialChangeEmail.php index 956ff77e8c..c95aa1b558 100644 --- a/includes/specials/SpecialChangeEmail.php +++ b/includes/specials/SpecialChangeEmail.php @@ -78,10 +78,6 @@ class SpecialChangeEmail extends FormSpecialPage { throw new PermissionsError( 'viewmyprivateinfo' ); } - if ( $user->isBlockedFromEmailuser() ) { - throw new UserBlockedError( $user->getBlock() ); - } - parent::checkExecutePermissions( $user ); } diff --git a/includes/specials/SpecialComparePages.php b/includes/specials/SpecialComparePages.php index 36928cae78..6d9dc0f038 100644 --- a/includes/specials/SpecialComparePages.php +++ b/includes/specials/SpecialComparePages.php @@ -49,6 +49,7 @@ class SpecialComparePages extends SpecialPage { $this->setHeaders(); $this->outputHeader(); $this->getOutput()->addModuleStyles( 'mediawiki.special' ); + $this->addHelpLink( 'Help:Diff' ); $form = HTMLForm::factory( 'ooui', [ 'Page1' => [ diff --git a/includes/specials/SpecialContributions.php b/includes/specials/SpecialContributions.php index d83853af7a..4f5c15099c 100644 --- a/includes/specials/SpecialContributions.php +++ b/includes/specials/SpecialContributions.php @@ -625,8 +625,7 @@ class SpecialContributions extends IncludableSpecialPage { [], Xml::label( $this->msg( 'namespace' )->text(), - 'namespace', - '' + 'namespace' ) . "\u{00A0}" . Html::namespaceSelector( [ 'selected' => $this->opts['namespace'], 'all' => '', 'in-user-lang' => true ], diff --git a/includes/specials/SpecialDeletedContributions.php b/includes/specials/SpecialDeletedContributions.php index 8817ba3de4..40d89625eb 100644 --- a/includes/specials/SpecialDeletedContributions.php +++ b/includes/specials/SpecialDeletedContributions.php @@ -46,6 +46,7 @@ class DeletedContributionsPage extends SpecialPage { $this->setHeaders(); $this->outputHeader(); $this->checkPermissions(); + $this->addHelpLink( 'Help:User contributions' ); $out = $this->getOutput(); $out->setPageTitle( $this->msg( 'deletedcontributions-title' ) ); diff --git a/includes/specials/SpecialDoubleRedirects.php b/includes/specials/SpecialDoubleRedirects.php index 77c59f0387..fcf1bb2797 100644 --- a/includes/specials/SpecialDoubleRedirects.php +++ b/includes/specials/SpecialDoubleRedirects.php @@ -198,6 +198,11 @@ class DoubleRedirectsPage extends QueryPage { return ( "{$linkA} {$edit} {$arr} {$linkB} {$arr} {$linkC}" ); } + public function execute( $par ) { + $this->addHelpLink( 'Help:Redirects' ); + parent::execute( $par ); + } + /** * Cache page content model and gender distinction for performance * diff --git a/includes/specials/SpecialEmailUser.php b/includes/specials/SpecialEmailUser.php index 5f80215632..b42cdea08a 100644 --- a/includes/specials/SpecialEmailUser.php +++ b/includes/specials/SpecialEmailUser.php @@ -166,14 +166,10 @@ class SpecialEmailUser extends UnlistedSpecialPage { * Validate target User * * @param string $target Target user name - * @param User|null $sender User sending the email + * @param User $sender User sending the email * @return User|string User object on success or a string on error */ - public static function getTarget( $target, User $sender = null ) { - if ( $sender === null ) { - wfDeprecated( __METHOD__ . ' without specifying the sending user', '1.30' ); - } - + public static function getTarget( $target, User $sender ) { if ( $target == '' ) { wfDebug( "Target is empty.\n" ); @@ -190,15 +186,11 @@ class SpecialEmailUser extends UnlistedSpecialPage { * Validate target User * * @param User $target Target user - * @param User|null $sender User sending the email + * @param User $sender User sending the email * @return string Error message or empty string if valid. * @since 1.30 */ - public static function validateTarget( $target, User $sender = null ) { - if ( $sender === null ) { - wfDeprecated( __METHOD__ . ' without specifying the sending user', '1.30' ); - } - + public static function validateTarget( $target, User $sender ) { if ( !$target instanceof User || !$target->getId() ) { wfDebug( "Target is invalid user.\n" ); @@ -217,25 +209,21 @@ class SpecialEmailUser extends UnlistedSpecialPage { return 'nowikiemail'; } - if ( $sender !== null && !$target->getOption( 'email-allow-new-users' ) && - $sender->isNewbie() - ) { + if ( !$target->getOption( 'email-allow-new-users' ) && $sender->isNewbie() ) { wfDebug( "User does not allow user emails from new users.\n" ); return 'nowikiemail'; } - if ( $sender !== null ) { - $blacklist = $target->getOption( 'email-blacklist', '' ); - if ( $blacklist ) { - $blacklist = MultiUsernameFilter::splitIds( $blacklist ); - $lookup = CentralIdLookup::factory(); - $senderId = $lookup->centralIdFromLocalUser( $sender ); - if ( $senderId !== 0 && in_array( $senderId, $blacklist ) ) { - wfDebug( "User does not allow user emails from this user.\n" ); + $blacklist = $target->getOption( 'email-blacklist', '' ); + if ( $blacklist ) { + $blacklist = MultiUsernameFilter::splitIds( $blacklist ); + $lookup = CentralIdLookup::factory(); + $senderId = $lookup->centralIdFromLocalUser( $sender ); + if ( $senderId !== 0 && in_array( $senderId, $blacklist ) ) { + wfDebug( "User does not allow user emails from this user.\n" ); - return 'nowikiemail'; - } + return 'nowikiemail'; } } @@ -387,6 +375,15 @@ class SpecialEmailUser extends UnlistedSpecialPage { $text .= $context->msg( 'emailuserfooter', $from->name, $to->name )->inContentLanguage()->text(); + if ( $config->get( 'EnableSpecialMute' ) ) { + $specialMutePage = SpecialPage::getTitleFor( 'Mute', $context->getUser()->getName() ); + $text .= "\n" . $context->msg( + 'specialmute-email-footer', + $specialMutePage->getCanonicalURL(), + $context->getUser()->getName() + )->inContentLanguage()->text(); + } + // Check and increment the rate limits if ( $context->getUser()->pingLimiter( 'emailuser' ) ) { throw new ThrottledError(); diff --git a/includes/specials/SpecialExport.php b/includes/specials/SpecialExport.php index ef61ac5b33..5a6358147f 100644 --- a/includes/specials/SpecialExport.php +++ b/includes/specials/SpecialExport.php @@ -24,6 +24,7 @@ */ use MediaWiki\Logger\LoggerFactory; +use MediaWiki\MediaWikiServices; /** * A special page that allows users to export pages in a XML file @@ -387,6 +388,8 @@ class SpecialExport extends SpecialPage { if ( $exportall ) { $exporter->allPages(); } else { + $permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); + foreach ( $pages as $page ) { # T10824: Only export pages the user can read $title = Title::newFromText( $page ); @@ -395,7 +398,7 @@ class SpecialExport extends SpecialPage { continue; } - if ( !$title->userCan( 'read', $this->getUser() ) ) { + if ( !$permissionManager->userCan( 'read', $this->getUser(), $title ) ) { // @todo Perhaps output an tag or something. continue; } diff --git a/includes/specials/SpecialFewestrevisions.php b/includes/specials/SpecialFewestrevisions.php index c47d87be2e..cf9da49eff 100644 --- a/includes/specials/SpecialFewestrevisions.php +++ b/includes/specials/SpecialFewestrevisions.php @@ -49,14 +49,15 @@ class FewestrevisionsPage extends QueryPage { 'namespace' => 'page_namespace', 'title' => 'page_title', 'value' => 'COUNT(*)', - 'redirect' => 'page_is_redirect' ], 'conds' => [ 'page_namespace' => MediaWikiServices::getInstance()->getNamespaceInfo()-> getContentNamespaces(), - 'page_id = rev_page' ], + 'page_id = rev_page', + 'page_is_redirect = 0', + ], 'options' => [ - 'GROUP BY' => [ 'page_namespace', 'page_title', 'page_is_redirect' ] + 'GROUP BY' => [ 'page_namespace', 'page_title' ] ] ]; } diff --git a/includes/specials/SpecialGoToInterwiki.php b/includes/specials/SpecialGoToInterwiki.php index 22a761262f..e7f4107f20 100644 --- a/includes/specials/SpecialGoToInterwiki.php +++ b/includes/specials/SpecialGoToInterwiki.php @@ -39,6 +39,19 @@ class SpecialGoToInterwiki extends UnlistedSpecialPage { } public function execute( $par ) { + // Allow forcing an interstitial for local interwikis. This is used + // when a redirect page is reached via a special page which resolves + // to a user-dependent value (as defined by + // RedirectSpecialPage::personallyIdentifiableTarget). See the hack + // for avoiding T109724 in MediaWiki::performRequest (which also + // explains why we can't use a query parameter instead). + // + // HHVM dies when substr_compare is used on an empty string so ensure it's not. + $force = ( substr_compare( $par ?: 'x', 'force/', 0, 6 ) === 0 ); + if ( $force ) { + $par = substr( $par, 6 ); + } + $this->setHeaders(); $target = Title::newFromText( $par ); // Disallow special pages as a precaution against @@ -50,9 +63,9 @@ class SpecialGoToInterwiki extends UnlistedSpecialPage { } $url = $target->getFullURL(); - if ( !$target->isExternal() || $target->isLocal() ) { + if ( !$target->isExternal() || ( $target->isLocal() && !$force ) ) { // Either a normal page, or a local interwiki. - // just redirect. + // Just redirect. $this->getOutput()->redirect( $url, '301' ); } else { $this->getOutput()->addWikiMsg( diff --git a/includes/specials/SpecialJavaScriptTest.php b/includes/specials/SpecialJavaScriptTest.php index c984af8dec..3e9676c64e 100644 --- a/includes/specials/SpecialJavaScriptTest.php +++ b/includes/specials/SpecialJavaScriptTest.php @@ -174,7 +174,7 @@ JAVASCRIPT // load before qunit/export. $scripts = $out->makeResourceLoaderLink( 'jquery.qunit', ResourceLoaderModule::TYPE_SCRIPTS, - [ 'raw' => true, 'sync' => true ] + [ 'raw' => '1', 'sync' => '1' ] ); $head = implode( "\n", [ $styles, $scripts ] ); diff --git a/includes/specials/SpecialListGroupRights.php b/includes/specials/SpecialListGroupRights.php index ae4b090d94..7f00311ca8 100644 --- a/includes/specials/SpecialListGroupRights.php +++ b/includes/specials/SpecialListGroupRights.php @@ -45,6 +45,7 @@ class SpecialListGroupRights extends SpecialPage { $out = $this->getOutput(); $out->addModuleStyles( 'mediawiki.special' ); + $this->addHelpLink( 'Help:User_rights_and_groups' ); $out->wrapWikiMsg( "
\n$1\n
", 'listgrouprights-key' ); diff --git a/includes/specials/SpecialListredirects.php b/includes/specials/SpecialListredirects.php index 48f364027e..3284c570ee 100644 --- a/includes/specials/SpecialListredirects.php +++ b/includes/specials/SpecialListredirects.php @@ -145,6 +145,11 @@ class ListredirectsPage extends QueryPage { } } + public function execute( $par ) { + $this->addHelpLink( 'Help:Redirects' ); + parent::execute( $par ); + } + protected function getGroupName() { return 'pages'; } diff --git a/includes/specials/SpecialMergeHistory.php b/includes/specials/SpecialMergeHistory.php index 89eb410304..5b77d5a112 100644 --- a/includes/specials/SpecialMergeHistory.php +++ b/includes/specials/SpecialMergeHistory.php @@ -21,6 +21,8 @@ * @ingroup SpecialPage */ +use MediaWiki\Storage\RevisionRecord; + /** * Special page allowing users with the appropriate permissions to * merge article histories, with some restrictions @@ -293,12 +295,12 @@ class SpecialMergeHistory extends SpecialPage { [], [ 'oldid' => $rev->getId() ] ); - if ( $rev->isDeleted( Revision::DELETED_TEXT ) ) { + if ( $rev->isDeleted( RevisionRecord::DELETED_TEXT ) ) { $pageLink = '' . $pageLink . ''; } # Last link - if ( !$rev->userCan( Revision::DELETED_TEXT, $user ) ) { + if ( !$rev->userCan( RevisionRecord::DELETED_TEXT, $user ) ) { $last = $this->msg( 'last' )->escaped(); } elseif ( isset( $this->prevId[$row->rev_id] ) ) { $last = $linkRenderer->makeKnownLink( diff --git a/includes/specials/SpecialMovepage.php b/includes/specials/SpecialMovepage.php index 15b7c632ae..ecbbc25bca 100644 --- a/includes/specials/SpecialMovepage.php +++ b/includes/specials/SpecialMovepage.php @@ -147,7 +147,7 @@ class MovePageForm extends UnlistedSpecialPage { $out = $this->getOutput(); $out->setPageTitle( $this->msg( 'move-page', $this->oldTitle->getPrefixedText() ) ); $out->addModuleStyles( 'mediawiki.special' ); - $out->addModules( 'mediawiki.special.movePage' ); + $out->addModules( 'mediawiki.misc-authed-ooui' ); $this->addHelpLink( 'Help:Moving a page' ); $out->addWikiMsg( $this->getConfig()->get( 'FixDoubleRedirects' ) ? @@ -597,7 +597,15 @@ class MovePageForm extends UnlistedSpecialPage { # Do the actual move. $mp = new MovePage( $ot, $nt ); + # check whether the requested actions are permitted / possible $userPermitted = $mp->checkPermissions( $user, $this->reason )->isOK(); + if ( $ot->isTalkPage() || $nt->isTalkPage() ) { + $this->moveTalk = false; + } + if ( $this->moveSubpages ) { + $permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); + $this->moveSubpages = $permissionManager->userCan( 'move-subpages', $user, $ot ); + } $status = $mp->moveIfAllowed( $user, $this->reason, $createRedirect ); if ( !$status->isOK() ) { @@ -646,19 +654,11 @@ class MovePageForm extends UnlistedSpecialPage { $movePage = $this; Hooks::run( 'SpecialMovepageAfterMove', [ &$movePage, &$ot, &$nt ] ); - # Now we move extra pages we've been asked to move: subpages and talk - # pages. First, if the old page or the new page is a talk page, we - # can't move any talk pages: cancel that. - if ( $ot->isTalkPage() || $nt->isTalkPage() ) { - $this->moveTalk = false; - } - - if ( count( $ot->getUserPermissionsErrors( 'move-subpages', $user ) ) ) { - $this->moveSubpages = false; - } - - /** - * Next make a list of id's. This might be marginally less efficient + /* + * Now we move extra pages we've been asked to move: subpages and talk + * pages. + * + * First, make a list of id's. This might be marginally less efficient * than a more direct method, but this is not a highly performance-cri- * tical code path and readable code is more important here. * diff --git a/includes/specials/SpecialMute.php b/includes/specials/SpecialMute.php new file mode 100644 index 0000000000..f3ae31a731 --- /dev/null +++ b/includes/specials/SpecialMute.php @@ -0,0 +1,230 @@ +getConfig(); + $this->enableUserEmailBlacklist = $config->get( 'EnableUserEmailBlacklist' ); + $this->enableUserEmail = $config->get( 'EnableUserEmail' ); + + $this->centralIdLookup = CentralIdLookup::factory(); + + parent::__construct( self::PAGE_NAME, '', false ); + } + + /** + * Entry point for special pages + * + * @param string $par + */ + public function execute( $par ) { + $this->requireLogin( 'specialmute-login-required' ); + $this->loadTarget( $par ); + + parent::execute( $par ); + + $out = $this->getOutput(); + $out->addModules( 'mediawiki.misc-authed-ooui' ); + } + + /** + * @inheritDoc + */ + public function requiresUnblock() { + return false; + } + + /** + * @inheritDoc + */ + protected function getDisplayFormat() { + return 'ooui'; + } + + /** + * @inheritDoc + */ + public function onSuccess() { + $out = $this->getOutput(); + $out->addWikiMsg( 'specialmute-success' ); + } + + /** + * @param array $data + * @param HTMLForm|null $form + * @return bool + */ + public function onSubmit( array $data, HTMLForm $form = null ) { + foreach ( $data as $userOption => $value ) { + if ( $value ) { + $this->muteTarget( $userOption ); + } else { + $this->unmuteTarget( $userOption ); + } + } + + return true; + } + + /** + * @inheritDoc + */ + public function getDescription() { + return $this->msg( 'specialmute' )->text(); + } + + /** + * Un-mute target + * + * @param string $userOption up_property key that holds the blacklist + */ + private function unmuteTarget( $userOption ) { + $blacklist = $this->getBlacklist( $userOption ); + + $key = array_search( $this->targetCentralId, $blacklist ); + if ( $key !== false ) { + unset( $blacklist[$key] ); + $blacklist = implode( "\n", $blacklist ); + + $user = $this->getUser(); + $user->setOption( $userOption, $blacklist ); + $user->saveSettings(); + } + } + + /** + * Mute target + * @param string $userOption up_property key that holds the blacklist + */ + private function muteTarget( $userOption ) { + // avoid duplicates just in case + if ( !$this->isTargetBlacklisted( $userOption ) ) { + $blacklist = $this->getBlacklist( $userOption ); + + $blacklist[] = $this->targetCentralId; + $blacklist = implode( "\n", $blacklist ); + + $user = $this->getUser(); + $user->setOption( $userOption, $blacklist ); + $user->saveSettings(); + } + } + + /** + * @inheritDoc + */ + protected function getForm() { + $form = parent::getForm(); + $form->setId( 'mw-specialmute-form' ); + $form->setHeaderText( $this->msg( 'specialmute-header', $this->target )->parse() ); + $form->setSubmitTextMsg( 'specialmute-submit' ); + $form->setSubmitID( 'save' ); + + return $form; + } + + /** + * @inheritDoc + */ + protected function getFormFields() { + $fields = []; + if ( + $this->enableUserEmailBlacklist && + $this->enableUserEmail && + $this->getUser()->getEmailAuthenticationTimestamp() + ) { + $fields['email-blacklist'] = [ + 'type' => 'check', + 'label-message' => 'specialmute-label-mute-email', + 'default' => $this->isTargetBlacklisted( 'email-blacklist' ), + ]; + } + + Hooks::run( 'SpecialMuteModifyFormFields', [ $this, &$fields ] ); + + if ( count( $fields ) == 0 ) { + throw new ErrorPageError( 'specialmute', 'specialmute-error-no-options' ); + } + + return $fields; + } + + /** + * @param string $username + */ + private function loadTarget( $username ) { + $target = User::newFromName( $username ); + if ( !$target || !$target->getId() ) { + throw new ErrorPageError( 'specialmute', 'specialmute-error-invalid-user' ); + } else { + $this->target = $target; + $this->targetCentralId = $this->centralIdLookup->centralIdFromLocalUser( $target ); + } + } + + /** + * @param string $userOption + * @return bool + */ + public function isTargetBlacklisted( $userOption ) { + $blacklist = $this->getBlacklist( $userOption ); + return in_array( $this->targetCentralId, $blacklist, true ); + } + + /** + * @param string $userOption + * @return array + */ + private function getBlacklist( $userOption ) { + $blacklist = $this->getUser()->getOption( $userOption ); + if ( !$blacklist ) { + return []; + } + + return MultiUsernameFilter::splitIds( $blacklist ); + } +} diff --git a/includes/specials/SpecialNewpages.php b/includes/specials/SpecialNewpages.php index 1b8ba85c70..04db7040dc 100644 --- a/includes/specials/SpecialNewpages.php +++ b/includes/specials/SpecialNewpages.php @@ -160,6 +160,8 @@ class SpecialNewpages extends IncludableSpecialPage { $navigation = $pager->getNavigationBar(); } $out->addHTML( $navigation . $pager->getBody() . $navigation ); + // Add styles for change tags + $out->addModuleStyles( 'mediawiki.interface.helpers.styles' ); } else { $out->addWikiMsg( 'specialpage-empty' ); } diff --git a/includes/specials/SpecialPageLanguage.php b/includes/specials/SpecialPageLanguage.php index 7e41305bb1..c0f004ffa6 100644 --- a/includes/specials/SpecialPageLanguage.php +++ b/includes/specials/SpecialPageLanguage.php @@ -43,7 +43,7 @@ class SpecialPageLanguage extends FormSpecialPage { } protected function preText() { - $this->getOutput()->addModules( 'mediawiki.special.pageLanguage' ); + $this->getOutput()->addModules( 'mediawiki.misc-authed-ooui' ); return parent::preText(); } diff --git a/includes/specials/SpecialProtectedpages.php b/includes/specials/SpecialProtectedpages.php index e9dca35923..38c6b1184a 100644 --- a/includes/specials/SpecialProtectedpages.php +++ b/includes/specials/SpecialProtectedpages.php @@ -38,6 +38,7 @@ class SpecialProtectedpages extends SpecialPage { $this->setHeaders(); $this->outputHeader(); $this->getOutput()->addModuleStyles( 'mediawiki.special' ); + $this->addHelpLink( 'Help:Protected_pages' ); $request = $this->getRequest(); $type = $request->getVal( $this->IdType ); diff --git a/includes/specials/SpecialProtectedtitles.php b/includes/specials/SpecialProtectedtitles.php index 5dc49ea8c7..4b0997e919 100644 --- a/includes/specials/SpecialProtectedtitles.php +++ b/includes/specials/SpecialProtectedtitles.php @@ -37,6 +37,7 @@ class SpecialProtectedtitles extends SpecialPage { function execute( $par ) { $this->setHeaders(); $this->outputHeader(); + $this->addHelpLink( 'Help:Protected_pages' ); $request = $this->getRequest(); $type = $request->getVal( $this->IdType ); diff --git a/includes/specials/SpecialRevisionDelete.php b/includes/specials/SpecialRevisionDelete.php index 682bceb394..7444225360 100644 --- a/includes/specials/SpecialRevisionDelete.php +++ b/includes/specials/SpecialRevisionDelete.php @@ -21,6 +21,8 @@ * @ingroup SpecialPage */ +use MediaWiki\Storage\RevisionRecord; + /** * Special page allowing users with the appropriate permissions to view * and hide revisions. Log items can also be hidden. @@ -197,12 +199,12 @@ class SpecialRevisionDelete extends UnlistedSpecialPage { [ $this->typeLabels['check-label'], 'wpHidePrimary', RevisionDeleter::getRevdelConstant( $this->typeName ) ], - [ 'revdelete-hide-comment', 'wpHideComment', Revision::DELETED_COMMENT ], - [ 'revdelete-hide-user', 'wpHideUser', Revision::DELETED_USER ] + [ 'revdelete-hide-comment', 'wpHideComment', RevisionRecord::DELETED_COMMENT ], + [ 'revdelete-hide-user', 'wpHideUser', RevisionRecord::DELETED_USER ] ]; if ( $user->isAllowed( 'suppressrevision' ) ) { $this->checks[] = [ 'revdelete-hide-restricted', - 'wpHideRestricted', Revision::DELETED_RESTRICTED ]; + 'wpHideRestricted', RevisionRecord::DELETED_RESTRICTED ]; } # Either submit or create our form @@ -530,7 +532,7 @@ class SpecialRevisionDelete extends UnlistedSpecialPage { $bitfield & $field ); - if ( $field == Revision::DELETED_RESTRICTED ) { + if ( $field == RevisionRecord::DELETED_RESTRICTED ) { $innerHTML = "$innerHTML"; } @@ -561,7 +563,7 @@ class SpecialRevisionDelete extends UnlistedSpecialPage { $line .= '' . Xml::radio( $name, 0, $selected == 0 ) . ''; $line .= '' . Xml::radio( $name, 1, $selected == 1 ) . ''; $label = $this->msg( $message )->escaped(); - if ( $field == Revision::DELETED_RESTRICTED ) { + if ( $field == RevisionRecord::DELETED_RESTRICTED ) { $label = "$label"; } $line .= "$label"; @@ -599,7 +601,7 @@ class SpecialRevisionDelete extends UnlistedSpecialPage { . $this->otherReason; } # Can the user set this field? - if ( $bitParams[Revision::DELETED_RESTRICTED] == 1 + if ( $bitParams[RevisionRecord::DELETED_RESTRICTED] == 1 && !$this->getUser()->isAllowed( 'suppressrevision' ) ) { throw new PermissionsError( 'suppressrevision' ); @@ -662,8 +664,8 @@ class SpecialRevisionDelete extends UnlistedSpecialPage { } $bitfield[$field] = $val; } - if ( !isset( $bitfield[Revision::DELETED_RESTRICTED] ) ) { - $bitfield[Revision::DELETED_RESTRICTED] = 0; + if ( !isset( $bitfield[RevisionRecord::DELETED_RESTRICTED] ) ) { + $bitfield[RevisionRecord::DELETED_RESTRICTED] = 0; } return $bitfield; diff --git a/includes/specials/SpecialSearch.php b/includes/specials/SpecialSearch.php index e1fbe6a488..ad045e43f3 100644 --- a/includes/specials/SpecialSearch.php +++ b/includes/specials/SpecialSearch.php @@ -79,7 +79,7 @@ class SpecialSearch extends SpecialPage { /** * @var string */ - protected $sort; + protected $sort = SearchEngine::DEFAULT_SORT; /** * @var bool @@ -92,6 +92,12 @@ class SpecialSearch extends SpecialPage { */ protected $searchConfig; + /** + * @var Status Holds any parameter validation errors that should + * be displayed back to the user. + */ + private $loadStatus; + const NAMESPACES_CURRENT = 'sense'; public function __construct() { @@ -204,6 +210,8 @@ class SpecialSearch extends SpecialPage { * @see tests/phpunit/includes/specials/SpecialSearchTest.php */ public function load() { + $this->loadStatus = new Status(); + $request = $this->getRequest(); list( $this->limit, $this->offset ) = $request->getLimitOffset( 20, '' ); $this->mPrefix = $request->getVal( 'prefix', '' ); @@ -211,8 +219,13 @@ class SpecialSearch extends SpecialPage { $this->setExtraParam( 'prefix', $this->mPrefix ); } - $this->sort = $request->getVal( 'sort', SearchEngine::DEFAULT_SORT ); - if ( $this->sort !== SearchEngine::DEFAULT_SORT ) { + $sort = $request->getVal( 'sort', SearchEngine::DEFAULT_SORT ); + $validSorts = $this->getSearchEngine()->getValidSorts(); + if ( !in_array( $sort, $validSorts ) ) { + $this->loadStatus->warning( 'search-invalid-sort-order', $sort, + implode( ', ', $validSorts ) ); + } elseif ( $sort !== $this->sort ) { + $this->sort = $sort; $this->setExtraParam( 'sort', $this->sort ); } @@ -247,6 +260,7 @@ class SpecialSearch extends SpecialPage { $this->namespaces = $profiles[$profile]['namespaces']; } else { // Unknown profile requested + $this->loadStatus->warning( 'search-unknown-profile', $profile ); $profile = 'default'; $this->namespaces = $profiles['default']['namespaces']; } @@ -375,17 +389,22 @@ class SpecialSearch extends SpecialPage { $out->addHTML( $dymWidget->render( $term, $textMatches ) ); } - $hasErrors = $textStatus && $textStatus->getErrors() !== []; + $hasSearchErrors = $textStatus && $textStatus->getErrors() !== []; $hasOtherResults = $textMatches && - $textMatches->hasInterwikiResults( SearchResultSet::INLINE_RESULTS ); + $textMatches->hasInterwikiResults( ISearchResultSet::INLINE_RESULTS ); - if ( $textMatches && $textMatches->hasInterwikiResults( SearchResultSet::SECONDARY_RESULTS ) ) { + if ( $textMatches && $textMatches->hasInterwikiResults( ISearchResultSet::SECONDARY_RESULTS ) ) { $out->addHTML( '
' ); } else { $out->addHTML( '
' ); } - if ( $hasErrors ) { + if ( $hasSearchErrors || $this->loadStatus->getErrors() ) { + if ( $textStatus === null ) { + $textStatus = $this->loadStatus; + } else { + $textStatus->merge( $this->loadStatus ); + } list( $error, $warning ) = $textStatus->splitByErrorType(); if ( $error->getErrors() ) { $out->addHTML( Html::errorBox( @@ -405,7 +424,7 @@ class SpecialSearch extends SpecialPage { Hooks::run( 'SpecialSearchResults', [ $term, &$titleMatches, &$textMatches ] ); // If we have no results and have not already displayed an error message - if ( $num === 0 && !$hasErrors ) { + if ( $num === 0 && !$hasSearchErrors ) { $out->wrapWikiMsg( "

\n$1

", [ $hasOtherResults ? 'search-nonefound-thiswiki' : 'search-nonefound', wfEscapeWikiText( $term ) @@ -443,14 +462,6 @@ class SpecialSearch extends SpecialPage { $term, $this->offset, $titleMatches, $textMatches ) ); - if ( $titleMatches ) { - $titleMatches->free(); - } - - if ( $textMatches ) { - $textMatches->free(); - } - $out->addHTML( '
' ); // prev/next links @@ -481,8 +492,8 @@ class SpecialSearch extends SpecialPage { /** * @param Title $title * @param int $num The number of search results found - * @param null|SearchResultSet $titleMatches Results from title search - * @param null|SearchResultSet $textMatches Results from text search + * @param null|ISearchResultSet $titleMatches Results from title search + * @param null|ISearchResultSet $textMatches Results from text search */ protected function showCreateLink( $title, $num, $titleMatches, $textMatches ) { // show direct page/create link if applicable diff --git a/includes/specials/SpecialTags.php b/includes/specials/SpecialTags.php index 110fb1ff04..9a95249c75 100644 --- a/includes/specials/SpecialTags.php +++ b/includes/specials/SpecialTags.php @@ -50,6 +50,7 @@ class SpecialTags extends SpecialPage { function execute( $par ) { $this->setHeaders(); $this->outputHeader(); + $this->addHelpLink( 'Manual:Tags' ); $request = $this->getRequest(); switch ( $par ) { diff --git a/includes/specials/SpecialUnblock.php b/includes/specials/SpecialUnblock.php index bedd2c58e5..31c277a27d 100644 --- a/includes/specials/SpecialUnblock.php +++ b/includes/specials/SpecialUnblock.php @@ -56,6 +56,7 @@ class SpecialUnblock extends SpecialPage { $this->setHeaders(); $this->outputHeader(); + $this->addHelpLink( 'Help:Blocking users' ); $out = $this->getOutput(); $out->setPageTitle( $this->msg( 'unblockip' ) ); @@ -162,7 +163,7 @@ class SpecialUnblock extends SpecialPage { * Submit callback for an HTMLForm object * @param array $data * @param HTMLForm $form - * @return array|bool Array(message key, parameters) + * @return array|bool [ message key, parameters ] */ public static function processUIUnblock( array $data, HTMLForm $form ) { return self::processUnblock( $data, $form->getContext() ); @@ -177,7 +178,7 @@ class SpecialUnblock extends SpecialPage { * @param array $data * @param IContextSource $context * @throws ErrorPageError - * @return array|bool Array( Array( message key, parameters ) ) on failure, True on success + * @return array|bool [ [ message key, parameters ] ] on failure, True on success */ public static function processUnblock( array $data, IContextSource $context ) { $performer = $context->getUser(); diff --git a/includes/specials/SpecialUncategorizedimages.php b/includes/specials/SpecialUncategorizedimages.php index 1cb27a3fc6..ed2d5cfbc8 100644 --- a/includes/specials/SpecialUncategorizedimages.php +++ b/includes/specials/SpecialUncategorizedimages.php @@ -31,6 +31,7 @@ class UncategorizedImagesPage extends ImageQueryPage { function __construct( $name = 'Uncategorizedimages' ) { parent::__construct( $name ); + $this->addHelpLink( 'Help:Categories' ); } function sortDescending() { diff --git a/includes/specials/SpecialUncategorizedpages.php b/includes/specials/SpecialUncategorizedpages.php index ab83af1c92..0b7da7bf8a 100644 --- a/includes/specials/SpecialUncategorizedpages.php +++ b/includes/specials/SpecialUncategorizedpages.php @@ -35,6 +35,7 @@ class UncategorizedPagesPage extends PageQueryPage { function __construct( $name = 'Uncategorizedpages' ) { parent::__construct( $name ); + $this->addHelpLink( 'Help:Categories' ); } function sortDescending() { diff --git a/includes/specials/SpecialUndelete.php b/includes/specials/SpecialUndelete.php index 456facef12..9a16a72926 100644 --- a/includes/specials/SpecialUndelete.php +++ b/includes/specials/SpecialUndelete.php @@ -138,8 +138,10 @@ class SpecialUndelete extends SpecialPage { */ protected function isAllowed( $permission, User $user = null ) { $user = $user ?: $this->getUser(); + $permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); + if ( $this->mTargetObj !== null ) { - return $this->mTargetObj->userCan( $permission, $user ); + return $permissionManager->userCan( $permission, $user, $this->mTargetObj ); } else { return $user->isAllowed( $permission ); } @@ -156,6 +158,7 @@ class SpecialUndelete extends SpecialPage { $this->setHeaders(); $this->outputHeader(); + $this->addHelpLink( 'Help:Deletion_and_undeletion' ); $this->loadRequest( $par ); $this->checkPermissions(); // Needs to be after mTargetObj is set @@ -185,7 +188,7 @@ class SpecialUndelete extends SpecialPage { if ( $this->mTimestamp !== '' ) { $this->showRevision( $this->mTimestamp ); } elseif ( $this->mFilename !== null && $this->mTargetObj->inNamespace( NS_FILE ) ) { - $file = new ArchivedFile( $this->mTargetObj, '', $this->mFilename ); + $file = new ArchivedFile( $this->mTargetObj, 0, $this->mFilename ); // Check if user is allowed to see this file if ( !$file->exists() ) { $out->addWikiMsg( 'filedelete-nofile', $this->mFilename ); @@ -384,11 +387,11 @@ class SpecialUndelete extends SpecialPage { return; } - if ( $rev->isDeleted( Revision::DELETED_TEXT ) ) { - if ( !$rev->userCan( Revision::DELETED_TEXT, $user ) ) { + if ( $rev->isDeleted( RevisionRecord::DELETED_TEXT ) ) { + if ( !$rev->userCan( RevisionRecord::DELETED_TEXT, $user ) ) { $out->wrapWikiMsg( "\n", - $rev->isDeleted( Revision::DELETED_RESTRICTED ) ? + $rev->isDeleted( RevisionRecord::DELETED_RESTRICTED ) ? 'rev-suppressed-text-permission' : 'rev-deleted-text-permission' ); @@ -397,7 +400,7 @@ class SpecialUndelete extends SpecialPage { $out->wrapWikiMsg( "\n", - $rev->isDeleted( Revision::DELETED_RESTRICTED ) ? + $rev->isDeleted( RevisionRecord::DELETED_RESTRICTED ) ? 'rev-suppressed-text-view' : 'rev-deleted-text-view' ); $out->addHTML( '
' ); @@ -649,7 +652,7 @@ class SpecialUndelete extends SpecialPage { $out = $this->getOutput(); $lang = $this->getLanguage(); $user = $this->getUser(); - $file = new ArchivedFile( $this->mTargetObj, '', $this->mFilename ); + $file = new ArchivedFile( $this->mTargetObj, 0, $this->mFilename ); $out->addWikiMsg( 'undelete-show-file-confirm', $this->mTargetObj->getText(), $lang->userDate( $file->getTimestamp(), $user ), @@ -929,7 +932,7 @@ class SpecialUndelete extends SpecialPage { if ( $this->mCanView ) { $titleObj = $this->getPageTitle(); # Last link - if ( !$rev->userCan( Revision::DELETED_TEXT, $this->getUser() ) ) { + if ( !$rev->userCan( RevisionRecord::DELETED_TEXT, $this->getUser() ) ) { $pageLink = htmlspecialchars( $this->getLanguage()->userTimeAndDate( $ts, $user ) ); $last = $this->msg( 'diff' )->escaped(); } elseif ( $remaining > 0 || ( $earliestLiveTime && $ts > $earliestLiveTime ) ) { @@ -1052,7 +1055,7 @@ class SpecialUndelete extends SpecialPage { $user = $this->getUser(); $time = $this->getLanguage()->userTimeAndDate( $ts, $user ); - if ( !$rev->userCan( Revision::DELETED_TEXT, $user ) ) { + if ( !$rev->userCan( RevisionRecord::DELETED_TEXT, $user ) ) { return '' . $time . ''; } @@ -1066,7 +1069,7 @@ class SpecialUndelete extends SpecialPage { ] ); - if ( $rev->isDeleted( Revision::DELETED_TEXT ) ) { + if ( $rev->isDeleted( RevisionRecord::DELETED_TEXT ) ) { $link = '' . $link . ''; } diff --git a/includes/specials/SpecialUserrights.php b/includes/specials/SpecialUserrights.php index 87bc259c47..87534eb885 100644 --- a/includes/specials/SpecialUserrights.php +++ b/includes/specials/SpecialUserrights.php @@ -161,11 +161,9 @@ class UserrightsPage extends SpecialPage { * allow them to change any user rights. */ if ( !$user->isAllowed( 'userrights' ) ) { - // @TODO Should the user be blocked from changing user rights if they - // are partially blocked? $block = $user->getBlock(); - if ( $block ) { - throw new UserBlockedError( $user->getBlock() ); + if ( $block && $block->isSitewide() ) { + throw new UserBlockedError( $block ); } } @@ -405,8 +403,6 @@ class UserrightsPage extends SpecialPage { wfDebug( 'newGroups: ' . print_r( $newGroups, true ) . "\n" ); wfDebug( 'oldUGMs: ' . print_r( $oldUGMs, true ) . "\n" ); wfDebug( 'newUGMs: ' . print_r( $newUGMs, true ) . "\n" ); - // Deprecated in favor of UserGroupsChanged hook - Hooks::run( 'UserRights', [ &$user, $add, $remove ], '1.26' ); // Only add a log entry if something actually changed if ( $newGroups != $oldGroups || $newUGMs != $oldUGMs ) { @@ -509,18 +505,18 @@ class UserrightsPage extends SpecialPage { $parts = explode( $this->getConfig()->get( 'UserrightsInterwikiDelimiter' ), $username ); if ( count( $parts ) < 2 ) { $name = trim( $username ); - $wikiId = ''; + $dbDomain = ''; } else { - list( $name, $wikiId ) = array_map( 'trim', $parts ); + list( $name, $dbDomain ) = array_map( 'trim', $parts ); - if ( WikiMap::isCurrentWikiId( $wikiId ) ) { - $wikiId = ''; + if ( WikiMap::isCurrentWikiId( $dbDomain ) ) { + $dbDomain = ''; } else { if ( $writing && !$this->getUser()->isAllowed( 'userrights-interwiki' ) ) { return Status::newFatal( 'userrights-no-interwiki' ); } - if ( !UserRightsProxy::validDatabase( $wikiId ) ) { - return Status::newFatal( 'userrights-nodatabase', $wikiId ); + if ( !UserRightsProxy::validDatabase( $dbDomain ) ) { + return Status::newFatal( 'userrights-nodatabase', $dbDomain ); } } } @@ -534,10 +530,10 @@ class UserrightsPage extends SpecialPage { // We'll do a lookup for the name internally. $id = intval( substr( $name, 1 ) ); - if ( $wikiId == '' ) { + if ( $dbDomain == '' ) { $name = User::whoIs( $id ); } else { - $name = UserRightsProxy::whoIs( $wikiId, $id ); + $name = UserRightsProxy::whoIs( $dbDomain, $id ); } if ( !$name ) { @@ -551,10 +547,10 @@ class UserrightsPage extends SpecialPage { } } - if ( $wikiId == '' ) { + if ( $dbDomain == '' ) { $user = User::newFromName( $name ); } else { - $user = UserRightsProxy::newFromName( $wikiId, $name ); + $user = UserRightsProxy::newFromName( $dbDomain, $name ); } if ( !$user || $user->isAnon() ) { @@ -1001,12 +997,12 @@ class UserrightsPage extends SpecialPage { /** * Returns $this->getUser()->changeableGroups() * - * @return array Array( - * 'add' => array( addablegroups ), - * 'remove' => array( removablegroups ), - * 'add-self' => array( addablegroups to self ), - * 'remove-self' => array( removable groups from self ) - * ) + * @return array [ + * 'add' => [ addablegroups ], + * 'remove' => [ removablegroups ], + * 'add-self' => [ addablegroups to self ], + * 'remove-self' => [ removable groups from self ] + * ] */ function changeableGroups() { return $this->getUser()->changeableGroups(); diff --git a/includes/specials/SpecialVersion.php b/includes/specials/SpecialVersion.php index 0c4959a48e..ec34db8611 100644 --- a/includes/specials/SpecialVersion.php +++ b/includes/specials/SpecialVersion.php @@ -219,23 +219,26 @@ class SpecialVersion extends SpecialPage { } /** - * Returns wiki text showing the third party software versions (apache, php, mysql). + * @since 1.34 * - * @return string + * @return array */ - public static function softwareInformation() { + public static function getSoftwareInformation() { $dbr = wfGetDB( DB_REPLICA ); // Put the software in an array of form 'name' => 'version'. All messages should // be loaded here, so feel free to use wfMessage in the 'name'. Raw HTML or // wikimarkup can be used. - $software = []; - $software['[https://www.mediawiki.org/ MediaWiki]'] = self::getVersionLinked(); + $software = [ + '[https://www.mediawiki.org/ MediaWiki]' => self::getVersionLinked() + ]; + if ( wfIsHHVM() ) { $software['[https://hhvm.com/ HHVM]'] = HHVM_VERSION . " (" . PHP_SAPI . ")"; } else { $software['[https://php.net/ PHP]'] = PHP_VERSION . " (" . PHP_SAPI . ")"; } + $software[$dbr->getSoftwareLink()] = $dbr->getServerInfo(); if ( defined( 'INTL_ICU_VERSION' ) ) { @@ -245,18 +248,27 @@ class SpecialVersion extends SpecialPage { // Allow a hook to add/remove items. Hooks::run( 'SoftwareInfo', [ &$software ] ); + return $software; + } + + /** + * Returns HTML showing the third party software versions (apache, php, mysql). + * + * @return string HTML table + */ + public static function softwareInformation() { $out = Xml::element( 'h2', [ 'id' => 'mw-version-software' ], wfMessage( 'version-software' )->text() ) . - Xml::openElement( 'table', [ 'class' => 'wikitable plainlinks', 'id' => 'sv-software' ] ) . - " - " . wfMessage( 'version-software-product' )->text() . " - " . wfMessage( 'version-software-version' )->text() . " - \n"; + Xml::openElement( 'table', [ 'class' => 'wikitable plainlinks', 'id' => 'sv-software' ] ) . + " + " . wfMessage( 'version-software-product' )->text() . " + " . wfMessage( 'version-software-version' )->text() . " + \n"; - foreach ( $software as $name => $version ) { + foreach ( self::getSoftwareInformation() as $name => $version ) { $out .= " " . $name . " " . $version . " @@ -812,7 +824,7 @@ class SpecialVersion extends SpecialPage { } // ... and generate the description; which can be a parameterized l10n message - // in the form array( , , ... ) or just a straight + // in the form [ , , ... ] or just a straight // up string if ( isset( $extension['descriptionmsg'] ) ) { // Localized description of extension diff --git a/includes/specials/forms/PreferencesFormOOUI.php b/includes/specials/forms/PreferencesFormOOUI.php index 9b86812dfa..5dae156993 100644 --- a/includes/specials/forms/PreferencesFormOOUI.php +++ b/includes/specials/forms/PreferencesFormOOUI.php @@ -146,9 +146,8 @@ class PreferencesFormOOUI extends OOUIHTMLForm { ) . $this->getFooterText( $key ); - $tabPanels[] = new OOUI\TabPanelLayout( [ + $tabPanels[] = new OOUI\TabPanelLayout( 'mw-prefsection-' . $key, [ 'classes' => [ 'mw-htmlform-autoinfuse-lazy' ], - 'name' => 'mw-prefsection-' . $key, 'label' => $label, 'content' => new OOUI\FieldsetLayout( [ 'classes' => [ 'mw-prefs-section-fieldset' ], diff --git a/includes/specials/helpers/LoginHelper.php b/includes/specials/helpers/LoginHelper.php index 6c9bea598f..f66eccf7bc 100644 --- a/includes/specials/helpers/LoginHelper.php +++ b/includes/specials/helpers/LoginHelper.php @@ -25,6 +25,7 @@ class LoginHelper extends ContextSource { 'resetpass-no-info', 'confirmemail_needlogin', 'prefsnologintext2', + 'specialmute-login-required', ]; /** diff --git a/includes/specials/pagers/AllMessagesTablePager.php b/includes/specials/pagers/AllMessagesTablePager.php index 6facda11eb..76e2ab7754 100644 --- a/includes/specials/pagers/AllMessagesTablePager.php +++ b/includes/specials/pagers/AllMessagesTablePager.php @@ -176,11 +176,11 @@ class AllMessagesTablePager extends TablePager { */ function reallyDoQuery( $offset, $limit, $order ) { $asc = ( $order === self::QUERY_ASCENDING ); - $result = new FakeResultWrapper( [] ); $messageNames = $this->getAllMessages( $order ); $statuses = self::getCustomisedStatuses( $messageNames, $this->langcode, $this->foreign ); + $rows = []; $count = 0; foreach ( $messageNames as $key ) { $customised = isset( $statuses['pages'][$key] ); @@ -190,7 +190,7 @@ class AllMessagesTablePager extends TablePager { ) { $actual = $this->msg( $key )->inLanguage( $this->lang )->plain(); $default = $this->msg( $key )->inLanguage( $this->lang )->useDatabase( false )->plain(); - $result->result[] = [ + $rows[] = [ 'am_title' => $key, 'am_actual' => $actual, 'am_default' => $default, @@ -205,7 +205,7 @@ class AllMessagesTablePager extends TablePager { } } - return $result; + return new FakeResultWrapper( $rows ); } protected function getStartBody() { diff --git a/includes/specials/pagers/BlockListPager.php b/includes/specials/pagers/BlockListPager.php index f7ad80cb8b..01aed22726 100644 --- a/includes/specials/pagers/BlockListPager.php +++ b/includes/specials/pagers/BlockListPager.php @@ -365,7 +365,7 @@ class BlockListPager extends TablePager { function getTotalAutoblocks() { $dbr = $this->getDatabase(); $res = $dbr->selectField( 'ipblocks', - [ 'COUNT(*) AS totalautoblocks' ], + 'COUNT(*)', [ 'ipb_auto' => '1', 'ipb_expiry >= ' . $dbr->addQuotes( $dbr->timestamp() ), diff --git a/includes/specials/pagers/ContribsPager.php b/includes/specials/pagers/ContribsPager.php index d82ba535a6..1cb78b88db 100644 --- a/includes/specials/pagers/ContribsPager.php +++ b/includes/specials/pagers/ContribsPager.php @@ -24,6 +24,7 @@ * @ingroup Pager */ use MediaWiki\MediaWikiServices; +use MediaWiki\Storage\RevisionRecord; use Wikimedia\Rdbms\IResultWrapper; use Wikimedia\Rdbms\FakeResultWrapper; use Wikimedia\Rdbms\IDatabase; @@ -347,10 +348,13 @@ class ContribsPager extends RangeChronologicalPager { // Paranoia: avoid brute force searches (T19342) if ( !$user->isAllowed( 'deletedhistory' ) ) { - $queryInfo['conds'][] = $this->mDb->bitAnd( 'rev_deleted', Revision::DELETED_USER ) . ' = 0'; + $queryInfo['conds'][] = $this->mDb->bitAnd( + 'rev_deleted', RevisionRecord::DELETED_USER + ) . ' = 0'; } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) { - $queryInfo['conds'][] = $this->mDb->bitAnd( 'rev_deleted', Revision::SUPPRESSED_USER ) . - ' != ' . Revision::SUPPRESSED_USER; + $queryInfo['conds'][] = $this->mDb->bitAnd( + 'rev_deleted', RevisionRecord::SUPPRESSED_USER + ) . ' != ' . RevisionRecord::SUPPRESSED_USER; } // $this->getIndexField() must be in the result rows, as reallyDoQuery() tries to access it. @@ -642,11 +646,12 @@ class ContribsPager extends RangeChronologicalPager { && $page->quickUserCan( 'edit', $user ) ) { $this->preventClickjacking(); - $topmarktext .= ' ' . Linker::generateRollback( $rev, $this->getContext() ); + $topmarktext .= ' ' . Linker::generateRollback( $rev, $this->getContext(), + [ 'noBrackets' ] ); } } # Is there a visible previous revision? - if ( $rev->userCan( Revision::DELETED_TEXT, $user ) && $rev->getParentId() !== 0 ) { + if ( $rev->userCan( RevisionRecord::DELETED_TEXT, $user ) && $rev->getParentId() !== 0 ) { $difftext = $linkRenderer->makeKnownLink( $page, new HtmlArmor( $this->messages['diff'] ), @@ -696,7 +701,7 @@ class ContribsPager extends RangeChronologicalPager { # Note that only unprivileged users have rows with hidden user names excluded. # When querying for an IP range, we want to always show user and user talk links. $userlink = ''; - if ( ( $this->contribs == 'newbie' && !$rev->isDeleted( Revision::DELETED_USER ) ) + if ( ( $this->contribs == 'newbie' && !$rev->isDeleted( RevisionRecord::DELETED_USER ) ) || $this->isQueryableRange( $this->target ) ) { $userlink = ' ' . $lang->getDirMark() @@ -756,7 +761,7 @@ class ContribsPager extends RangeChronologicalPager { ]; # Denote if username is redacted for this edit - if ( $rev->isDeleted( Revision::DELETED_USER ) ) { + if ( $rev->isDeleted( RevisionRecord::DELETED_USER ) ) { $templateParams['rev-deleted-user-contribs'] = $this->msg( 'rev-deleted-user-contribs' )->escaped(); } diff --git a/includes/specials/pagers/DeletedContribsPager.php b/includes/specials/pagers/DeletedContribsPager.php index 11a8532092..88e1ea881c 100644 --- a/includes/specials/pagers/DeletedContribsPager.php +++ b/includes/specials/pagers/DeletedContribsPager.php @@ -23,6 +23,7 @@ * @ingroup Pager */ use MediaWiki\MediaWikiServices; +use MediaWiki\Storage\RevisionRecord; use Wikimedia\Rdbms\IDatabase; use Wikimedia\Rdbms\IResultWrapper; use Wikimedia\Rdbms\FakeResultWrapper; @@ -88,10 +89,10 @@ class DeletedContribsPager extends IndexPager { $user = $this->getUser(); // Paranoia: avoid brute force searches (T19792) if ( !$user->isAllowed( 'deletedhistory' ) ) { - $conds[] = $this->mDb->bitAnd( 'ar_deleted', Revision::DELETED_USER ) . ' = 0'; + $conds[] = $this->mDb->bitAnd( 'ar_deleted', RevisionRecord::DELETED_USER ) . ' = 0'; } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) { - $conds[] = $this->mDb->bitAnd( 'ar_deleted', Revision::SUPPRESSED_USER ) . - ' != ' . Revision::SUPPRESSED_USER; + $conds[] = $this->mDb->bitAnd( 'ar_deleted', RevisionRecord::SUPPRESSED_USER ) . + ' != ' . RevisionRecord::SUPPRESSED_USER; } $commentQuery = CommentStore::getStore()->getJoin( 'ar_comment' ); @@ -337,7 +338,7 @@ class DeletedContribsPager extends IndexPager { $comment = Linker::revComment( $rev ); $date = $this->getLanguage()->userTimeAndDate( $rev->getTimestamp(), $user ); - if ( !$user->isAllowed( 'undelete' ) || !$rev->userCan( Revision::DELETED_TEXT, $user ) ) { + if ( !$user->isAllowed( 'undelete' ) || !$rev->userCan( RevisionRecord::DELETED_TEXT, $user ) ) { $link = htmlspecialchars( $date ); // unusable link } else { $link = $linkRenderer->makeKnownLink( @@ -351,7 +352,7 @@ class DeletedContribsPager extends IndexPager { ); } // Style deleted items - if ( $rev->isDeleted( Revision::DELETED_TEXT ) ) { + if ( $rev->isDeleted( RevisionRecord::DELETED_TEXT ) ) { $link = '' . $link . ''; } @@ -384,7 +385,7 @@ class DeletedContribsPager extends IndexPager { $ret = "{$del}{$link} {$tools} {$separator} {$mflag} {$pagelink} {$comment}"; # Denote if username is redacted for this edit - if ( $rev->isDeleted( Revision::DELETED_USER ) ) { + if ( $rev->isDeleted( RevisionRecord::DELETED_USER ) ) { $ret .= " " . $this->msg( 'rev-deleted-user-contribs' )->escaped() . ""; } diff --git a/includes/specials/pagers/ImageListPager.php b/includes/specials/pagers/ImageListPager.php index 1d29efbf08..2d3b6b291f 100644 --- a/includes/specials/pagers/ImageListPager.php +++ b/includes/specials/pagers/ImageListPager.php @@ -471,14 +471,16 @@ class ImageListPager extends TablePager { ); $download = Xml::element( 'a', - [ 'href' => $services->getRepoGroup()->findFile( $filePage )->getUrl() ], + [ 'href' => $services->getRepoGroup()->getLocalRepo()->newFile( $filePage )->getUrl() ], $imgfile ); $download = $this->msg( 'parentheses' )->rawParams( $download )->escaped(); // Add delete links if allowed // From https://github.com/Wikia/app/pull/3859 - if ( $filePage->userCan( 'delete', $this->getUser() ) ) { + $permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); + + if ( $permissionManager->userCan( 'delete', $this->getUser(), $filePage ) ) { $deleteMsg = $this->msg( 'listfiles-delete' )->text(); $delete = $linkRenderer->makeKnownLink( diff --git a/includes/title/NamespaceInfo.php b/includes/title/NamespaceInfo.php index cdb8f2554f..2ed8729ac6 100644 --- a/includes/title/NamespaceInfo.php +++ b/includes/title/NamespaceInfo.php @@ -142,6 +142,8 @@ class NamespaceInfo { * * @param int $index Namespace index * @return int + * @throws MWException if the given namespace doesn't have an associated talk namespace + * (e.g. NS_SPECIAL). */ public function getTalk( $index ) { $this->isMethodValidFor( $index, __METHOD__ ); @@ -151,15 +153,52 @@ class NamespaceInfo { } /** + * Get a LinkTarget referring to the talk page of $target. + * + * @see canHaveTalkPage * @param LinkTarget $target * @return LinkTarget Talk page for $target - * @throws MWException if $target's namespace doesn't have talk pages (e.g., NS_SPECIAL) + * @throws MWException if $target doesn't have talk pages, e.g. because it's in NS_SPECIAL, + * because it's a relative section-only link, or it's an an interwiki link. */ public function getTalkPage( LinkTarget $target ) : LinkTarget { + if ( $target->getText() === '' ) { + throw new MWException( 'Can\'t determine talk page associated with relative section link' ); + } + + if ( $target->getInterwiki() !== '' ) { + throw new MWException( 'Can\'t determine talk page associated with interwiki link' ); + } + if ( $this->isTalk( $target->getNamespace() ) ) { return $target; } - return new TitleValue( $this->getTalk( $target->getNamespace() ), $target->getDbKey() ); + + // NOTE: getTalk throws on bad namespaces! + return new TitleValue( $this->getTalk( $target->getNamespace() ), $target->getDBkey() ); + } + + /** + * Can the title have a corresponding talk page? + * + * False for relative section-only links (with getText() === ''), + * interwiki links (with getInterwiki() !== ''), and pages in NS_SPECIAL. + * + * @see getTalkPage + * + * @param LinkTarget $target + * @return bool True if this title either is a talk page or can have a talk page associated. + */ + public function canHaveTalkPage( LinkTarget $target ) { + if ( $target->getText() === '' || $target->getInterwiki() !== '' ) { + return false; + } + + if ( $target->getNamespace() < NS_MAIN ) { + return false; + } + + return true; } /** @@ -188,7 +227,7 @@ class NamespaceInfo { if ( $this->isSubject( $target->getNamespace() ) ) { return $target; } - return new TitleValue( $this->getSubject( $target->getNamespace() ), $target->getDbKey() ); + return new TitleValue( $this->getSubject( $target->getNamespace() ), $target->getDBkey() ); } /** @@ -216,8 +255,16 @@ class NamespaceInfo { * @throws MWException if $target's namespace doesn't have talk pages (e.g., NS_SPECIAL) */ public function getAssociatedPage( LinkTarget $target ) : LinkTarget { + if ( $target->getText() === '' ) { + throw new MWException( 'Can\'t determine talk page associated with relative section link' ); + } + + if ( $target->getInterwiki() !== '' ) { + throw new MWException( 'Can\'t determine talk page associated with interwiki link' ); + } + return new TitleValue( - $this->getAssociated( $target->getNamespace() ), $target->getDbKey() ); + $this->getAssociated( $target->getNamespace() ), $target->getDBkey() ); } /** diff --git a/includes/upload/UploadBase.php b/includes/upload/UploadBase.php index ae5b73249d..5b15e82f34 100644 --- a/includes/upload/UploadBase.php +++ b/includes/upload/UploadBase.php @@ -20,7 +20,6 @@ * @file * @ingroup Upload */ -use MediaWiki\MediaWikiServices; use MediaWiki\Shell\Shell; /** @@ -42,13 +41,36 @@ abstract class UploadBase { protected $mTempPath; /** @var TempFSFile|null Wrapper to handle deleting the temp file */ protected $tempFileObj; - - protected $mDesiredDestName, $mDestName, $mRemoveTempFile, $mSourceType; - protected $mTitle = false, $mTitleError = 0; - protected $mFilteredName, $mFinalExtension; - protected $mLocalFile, $mStashFile, $mFileSize, $mFileProps; + /** @var string|null */ + protected $mDesiredDestName; + /** @var string|null */ + protected $mDestName; + /** @var string|null */ + protected $mRemoveTempFile; + /** @var string|null */ + protected $mSourceType; + /** @var Title|bool */ + protected $mTitle = false; + /** @var int */ + protected $mTitleError = 0; + /** @var string|null */ + protected $mFilteredName; + /** @var string|null */ + protected $mFinalExtension; + /** @var LocalFile */ + protected $mLocalFile; + /** @var UploadStashFile */ + protected $mStashFile; + /** @var int|null */ + protected $mFileSize; + /** @var array|null */ + protected $mFileProps; + /** @var string[] */ protected $mBlackListedExtensions; - protected $mJavaDetected, $mSVGNSError; + /** @var bool|null */ + protected $mJavaDetected; + /** @var string|null */ + protected $mSVGNSError; protected static $safeXmlEncodings = [ 'UTF-8', @@ -357,13 +379,6 @@ abstract class UploadBase { return $result; } - $error = ''; - if ( !Hooks::run( 'UploadVerification', - [ $this->mDestName, $this->mTempPath, &$error ], '1.28' ) - ) { - return [ 'status' => self::HOOK_ABORTED, 'error' => $error ]; - } - return [ 'status' => self::OK ]; } @@ -1107,6 +1122,8 @@ abstract class UploadBase { * @throws UploadStashNotLoggedInException */ public function stashFile( User $user = null ) { + wfDeprecated( __METHOD__, '1.28' ); + return $this->doStashFile( $user ); } @@ -1124,29 +1141,6 @@ abstract class UploadBase { return $file; } - /** - * Stash a file in a temporary directory, returning a key which can be used - * to find the file again. See stashFile(). - * - * @deprecated since 1.28 - * @return string File key - */ - public function stashFileGetKey() { - wfDeprecated( __METHOD__, '1.28' ); - return $this->doStashFile()->getFileKey(); - } - - /** - * alias for stashFileGetKey, for backwards compatibility - * - * @deprecated since 1.28 - * @return string File key - */ - public function stashSession() { - wfDeprecated( __METHOD__, '1.28' ); - return $this->doStashFile()->getFileKey(); - } - /** * If we've modified the upload file we need to manually remove it * on exit to clean up. @@ -1508,7 +1502,7 @@ abstract class UploadBase { * @todo Replace this with a whitelist filter! * @param string $element * @param array $attribs - * @param array|null $data + * @param string|null $data * @return bool|array */ public function checkSvgScriptCallback( $element, $attribs, $data = null ) { @@ -2191,10 +2185,10 @@ abstract class UploadBase { * @return Status[]|bool */ public static function getSessionStatus( User $user, $statusKey ) { - $cache = MediaWikiServices::getInstance()->getMainObjectStash(); - $key = $cache->makeKey( 'uploadstatus', $user->getId() ?: md5( $user->getName() ), $statusKey ); + $store = self::getUploadSessionStore(); + $key = self::getUploadSessionKey( $store, $user, $statusKey ); - return $cache->get( $key ); + return $store->get( $key ); } /** @@ -2202,19 +2196,42 @@ abstract class UploadBase { * * The value will be set in cache for 1 day * + * Avoid triggering this method on HTTP GET/HEAD requests + * * @param User $user * @param string $statusKey * @param array|bool $value * @return void */ public static function setSessionStatus( User $user, $statusKey, $value ) { - $cache = MediaWikiServices::getInstance()->getMainObjectStash(); - $key = $cache->makeKey( 'uploadstatus', $user->getId() ?: md5( $user->getName() ), $statusKey ); + $store = self::getUploadSessionStore(); + $key = self::getUploadSessionKey( $store, $user, $statusKey ); if ( $value === false ) { - $cache->delete( $key ); + $store->delete( $key ); } else { - $cache->set( $key, $value, $cache::TTL_DAY ); + $store->set( $key, $value, $store::TTL_DAY ); } } + + /** + * @param BagOStuff $store + * @param User $user + * @param string $statusKey + * @return string + */ + private static function getUploadSessionKey( BagOStuff $store, User $user, $statusKey ) { + return $store->makeKey( + 'uploadstatus', + $user->getId() ?: md5( $user->getName() ), + $statusKey + ); + } + + /** + * @return BagOStuff + */ + private static function getUploadSessionStore() { + return ObjectCache::getInstance( 'db-replicated' ); + } } diff --git a/includes/upload/UploadFromChunks.php b/includes/upload/UploadFromChunks.php index 3e88fcd740..cc527e7e4b 100644 --- a/includes/upload/UploadFromChunks.php +++ b/includes/upload/UploadFromChunks.php @@ -28,12 +28,19 @@ * @author Michael Dale */ class UploadFromChunks extends UploadFromFile { + /** @var LocalRepo */ + private $repo; + /** @var UploadStash */ + public $stash; + /** @var User */ + public $user; + protected $mOffset; protected $mChunkIndex; protected $mFileKey; protected $mVirtualTempPath; - /** @var LocalRepo */ - private $repo; + + /** @noinspection PhpMissingParentConstructorInspection */ /** * Setup local pointers to stash, repo and user (similar to UploadFromStash) @@ -79,30 +86,9 @@ class UploadFromChunks extends UploadFromFile { */ public function stashFile( User $user = null ) { wfDeprecated( __METHOD__, '1.28' ); - $this->verifyChunk(); - return parent::stashFile( $user ); - } - /** - * @inheritDoc - * @throws UploadChunkVerificationException - * @deprecated since 1.28 - */ - public function stashFileGetKey() { - wfDeprecated( __METHOD__, '1.28' ); $this->verifyChunk(); - return parent::stashFileGetKey(); - } - - /** - * @inheritDoc - * @throws UploadChunkVerificationException - * @deprecated since 1.28 - */ - public function stashSession() { - wfDeprecated( __METHOD__, '1.28' ); - $this->verifyChunk(); - return parent::stashSession(); + return parent::stashFile( $user ); } /** diff --git a/includes/upload/UploadStash.php b/includes/upload/UploadStash.php index d39975db1b..215bd2041f 100644 --- a/includes/upload/UploadStash.php +++ b/includes/upload/UploadStash.php @@ -433,7 +433,7 @@ class UploadStash { * List all files in the stash. * * @throws UploadStashNotLoggedInException - * @return array + * @return array|false */ public function listFiles() { if ( !$this->isLoggedIn ) { diff --git a/includes/user/BotPassword.php b/includes/user/BotPassword.php index 6db219deec..df5edef2f3 100644 --- a/includes/user/BotPassword.php +++ b/includes/user/BotPassword.php @@ -21,7 +21,7 @@ use MediaWiki\Auth\AuthenticationResponse; use MediaWiki\MediaWikiServices; use MediaWiki\Session\BotPasswordSessionProvider; -use Wikimedia\Rdbms\IMaintainableDatabase; +use Wikimedia\Rdbms\IDatabase; /** * Utility class for bot passwords @@ -71,7 +71,7 @@ class BotPassword implements IDBAccessObject { /** * Get a database connection for the bot passwords database * @param int $db Index of the connection to get, e.g. DB_MASTER or DB_REPLICA. - * @return IMaintainableDatabase + * @return IDatabase */ public static function getDB( $db ) { global $wgBotPasswordsCluster, $wgBotPasswordsDatabase; diff --git a/includes/user/User.php b/includes/user/User.php index bdcb17b9b3..7c2f0380fa 100644 --- a/includes/user/User.php +++ b/includes/user/User.php @@ -111,95 +111,7 @@ class User implements IDBAccessObject, UserIdentity { ]; /** - * Array of Strings Core rights. - * Each of these should have a corresponding message of the form - * "right-$right". - * @showinitializer * @var string[] - */ - protected static $mCoreRights = [ - 'apihighlimits', - 'applychangetags', - 'autoconfirmed', - 'autocreateaccount', - 'autopatrol', - 'bigdelete', - 'block', - 'blockemail', - 'bot', - 'browsearchive', - 'changetags', - 'createaccount', - 'createpage', - 'createtalk', - 'delete', - 'deletechangetags', - 'deletedhistory', - 'deletedtext', - 'deletelogentry', - 'deleterevision', - 'edit', - 'editcontentmodel', - 'editinterface', - 'editprotected', - 'editmyoptions', - 'editmyprivateinfo', - 'editmyusercss', - 'editmyuserjson', - 'editmyuserjs', - 'editmywatchlist', - 'editsemiprotected', - 'editsitecss', - 'editsitejson', - 'editsitejs', - 'editusercss', - 'edituserjson', - 'edituserjs', - 'hideuser', - 'import', - 'importupload', - 'ipblock-exempt', - 'managechangetags', - 'markbotedits', - 'mergehistory', - 'minoredit', - 'move', - 'movefile', - 'move-categorypages', - 'move-rootuserpages', - 'move-subpages', - 'nominornewtalk', - 'noratelimit', - 'override-export-depth', - 'pagelang', - 'patrol', - 'patrolmarks', - 'protect', - 'purge', - 'read', - 'reupload', - 'reupload-own', - 'reupload-shared', - 'rollback', - 'sendemail', - 'siteadmin', - 'suppressionlog', - 'suppressredirect', - 'suppressrevision', - 'unblockself', - 'undelete', - 'unwatchedpages', - 'upload', - 'upload_by_url', - 'userrights', - 'userrights-interwiki', - 'viewmyprivateinfo', - 'viewmywatchlist', - 'viewsuppressed', - 'writeapi', - ]; - - /** * @var string[] Cached results of getAllRights() */ protected static $mAllRights = false; @@ -274,8 +186,6 @@ class User implements IDBAccessObject, UserIdentity { public $mBlockedby; /** @var string */ protected $mHash; - /** @var array */ - public $mRights; /** @var string */ protected $mBlockreason; /** @var array */ @@ -333,6 +243,37 @@ class User implements IDBAccessObject, UserIdentity { return (string)$this->getName(); } + public function &__get( $name ) { + // A shortcut for $mRights deprecation phase + if ( $name === 'mRights' ) { + $copy = $this->getRights(); + return $copy; + } elseif ( !property_exists( $this, $name ) ) { + // T227688 - do not break $u->foo['bar'] = 1 + wfLogWarning( 'tried to get non-existent property' ); + $this->$name = null; + return $this->$name; + } else { + wfLogWarning( 'tried to get non-visible property' ); + return null; + } + } + + public function __set( $name, $value ) { + // A shortcut for $mRights deprecation phase, only known legitimate use was for + // testing purposes, other uses seem bad in principle + if ( $name === 'mRights' ) { + MediaWikiServices::getInstance()->getPermissionManager()->overrideUserRightsForTesting( + $this, + is_null( $value ) ? [] : $value + ); + } elseif ( !property_exists( $this, $name ) ) { + $this->$name = $value; + } else { + wfLogWarning( 'tried to set non-visible property' ); + } + } + /** * Test if it's safe to load this User object. * @@ -491,12 +432,12 @@ class User implements IDBAccessObject, UserIdentity { /** * @since 1.27 - * @param string $wikiId + * @param string $dbDomain * @param int $userId */ - public static function purge( $wikiId, $userId ) { + public static function purge( $dbDomain, $userId ) { $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); - $key = $cache->makeGlobalKey( 'user', 'id', $wikiId, $userId ); + $key = $cache->makeGlobalKey( 'user', 'id', $dbDomain, $userId ); $cache->delete( $key ); } @@ -680,16 +621,16 @@ class User implements IDBAccessObject, UserIdentity { * @param int|null $userId User ID, if known * @param string|null $userName User name, if known * @param int|null $actorId Actor ID, if known - * @param bool|string $wikiId remote wiki to which the User/Actor ID applies, or false if none + * @param bool|string $dbDomain remote wiki to which the User/Actor ID applies, or false if none * @return User */ - public static function newFromAnyId( $userId, $userName, $actorId, $wikiId = false ) { + public static function newFromAnyId( $userId, $userName, $actorId, $dbDomain = false ) { global $wgActorTableSchemaMigrationStage; // Stop-gap solution for the problem described in T222212. // Force the User ID and Actor ID to zero for users loaded from the database // of another wiki, to prevent subtle data corruption and confusing failure modes. - if ( $wikiId !== false ) { + if ( $dbDomain !== false ) { $userId = 0; $actorId = 0; } @@ -950,12 +891,12 @@ class User implements IDBAccessObject, UserIdentity { $result = (int)$s->user_id; } - self::$idCacheByName[$name] = $result; - - if ( count( self::$idCacheByName ) > 1000 ) { + if ( count( self::$idCacheByName ) >= 1000 ) { self::$idCacheByName = []; } + self::$idCacheByName[$name] = $result; + return $result; } @@ -1346,25 +1287,17 @@ class User implements IDBAccessObject, UserIdentity { * @return bool True if the user is logged in, false otherwise. */ private function loadFromSession() { - // Deprecated hook - $result = null; - Hooks::run( 'UserLoadFromSession', [ $this, &$result ], '1.27' ); - if ( $result !== null ) { - return $result; - } - // MediaWiki\Session\Session already did the necessary authentication of the user // returned here, so just use it if applicable. $session = $this->getRequest()->getSession(); $user = $session->getUser(); if ( $user->isLoggedIn() ) { $this->loadFromUserObject( $user ); - if ( $user->getBlock() ) { - // If this user is autoblocked, set a cookie to track the block. This has to be done on - // every session load, because an autoblocked editor might not edit again from the same - // IP address after being blocked. - MediaWikiServices::getInstance()->getBlockManager()->trackBlockWithCookie( $this ); - } + + // If this user is autoblocked, set a cookie to track the block. This has to be done on + // every session load, because an autoblocked editor might not edit again from the same + // IP address after being blocked. + MediaWikiServices::getInstance()->getBlockManager()->trackBlockWithCookie( $this ); // Other code expects these to be set in the session, so set them. $session->set( 'wsUserID', $this->getId() ); @@ -1706,11 +1639,12 @@ class User implements IDBAccessObject, UserIdentity { * given source. May be "name", "id", "actor", "defaults", "session", or false for no reload. */ public function clearInstanceCache( $reloadFrom = false ) { + global $wgFullyInitialised; + $this->mNewtalk = -1; $this->mDatePreference = null; $this->mBlockedby = -1; # Unset $this->mHash = false; - $this->mRights = null; $this->mEffectiveGroups = null; $this->mImplicitGroups = null; $this->mGroupMemberships = null; @@ -1718,6 +1652,13 @@ class User implements IDBAccessObject, UserIdentity { $this->mOptionsLoaded = false; $this->mEditCount = null; + // Replacement of former `$this->mRights = null` line + if ( $wgFullyInitialised && $this->mFrom ) { + MediaWikiServices::getInstance()->getPermissionManager()->invalidateUsersRightsCache( + $this + ); + } + if ( $reloadFrom ) { $this->mLoadedItems = []; $this->mFrom = $reloadFrom; @@ -1822,8 +1763,7 @@ class User implements IDBAccessObject, UserIdentity { $fromReplica ); - if ( $block instanceof AbstractBlock ) { - wfDebug( __METHOD__ . ": Found block.\n" ); + if ( $block ) { $this->mBlock = $block; $this->mBlockedby = $block->getByName(); $this->mBlockreason = $block->getReason(); @@ -2157,7 +2097,6 @@ class User implements IDBAccessObject, UserIdentity { * @param Title $title Title to check * @param bool $fromReplica Whether to check the replica DB instead of the master * @return bool - * @throws MWException * * @deprecated since 1.33, * use MediaWikiServices::getInstance()->getPermissionManager()->isBlockedFrom(..) @@ -2645,7 +2584,7 @@ class User implements IDBAccessObject, UserIdentity { if ( $mode === 'refresh' ) { $cache->delete( $key, 1 ); // low tombstone/"hold-off" TTL } else { - $lb->getConnection( DB_MASTER )->onTransactionPreCommitOrIdle( + $lb->getConnectionRef( DB_MASTER )->onTransactionPreCommitOrIdle( function () use ( $cache, $key ) { $cache->delete( $key ); }, @@ -3298,7 +3237,7 @@ class User implements IDBAccessObject, UserIdentity { * and 'all', which forces a reset of *all* preferences and overrides everything else. * * @param array|string $resetKinds Which kinds of preferences to reset. Defaults to - * array( 'registered', 'registered-multiselect', 'registered-checkmatrix', 'unused' ) + * [ 'registered', 'registered-multiselect', 'registered-checkmatrix', 'unused' ] * for backwards-compatibility. * @param IContextSource|null $context Context source used when $resetKinds * does not contain 'all', passed to getOptionKinds(). @@ -3403,44 +3342,13 @@ class User implements IDBAccessObject, UserIdentity { /** * Get the permissions this user has. * @return string[] permission names + * + * @deprecated since 1.34, use MediaWikiServices::getInstance()->getPermissionManager() + * ->getUserPermissions(..) instead + * */ public function getRights() { - if ( is_null( $this->mRights ) ) { - $this->mRights = self::getGroupPermissions( $this->getEffectiveGroups() ); - Hooks::run( 'UserGetRights', [ $this, &$this->mRights ] ); - - // Deny any rights denied by the user's session, unless this - // endpoint has no sessions. - if ( !defined( 'MW_NO_SESSION' ) ) { - $allowedRights = $this->getRequest()->getSession()->getAllowedUserRights(); - if ( $allowedRights !== null ) { - $this->mRights = array_intersect( $this->mRights, $allowedRights ); - } - } - - Hooks::run( 'UserGetRightsRemove', [ $this, &$this->mRights ] ); - // Force reindexation of rights when a hook has unset one of them - $this->mRights = array_values( array_unique( $this->mRights ) ); - - // If block disables login, we should also remove any - // extra rights blocked users might have, in case the - // blocked user has a pre-existing session (T129738). - // This is checked here for cases where people only call - // $user->isAllowed(). It is also checked in Title::checkUserBlock() - // to give a better error message in the common case. - $config = RequestContext::getMain()->getConfig(); - // @TODO Partial blocks should not prevent the user from logging in. - // see: https://phabricator.wikimedia.org/T208895 - if ( - $this->isLoggedIn() && - $config->get( 'BlockDisablesLogin' ) && - $this->getBlock() - ) { - $anon = new User; - $this->mRights = array_intersect( $this->mRights, $anon->getRights() ); - } - } - return $this->mRights; + return MediaWikiServices::getInstance()->getPermissionManager()->getUserPermissions( $this ); } /** @@ -3568,7 +3476,7 @@ class User implements IDBAccessObject, UserIdentity { if ( $count === null ) { // it has not been initialized. do so. - $count = $this->initEditCountInternal(); + $count = $this->initEditCountInternal( $dbr ); } $this->mEditCount = $count; } @@ -3609,8 +3517,7 @@ class User implements IDBAccessObject, UserIdentity { // Refresh the groups caches, and clear the rights cache so it will be // refreshed on the next call to $this->getRights(). $this->getEffectiveGroups( true ); - $this->mRights = null; - + MediaWikiServices::getInstance()->getPermissionManager()->invalidateUsersRightsCache( $this ); $this->invalidateCache(); return true; @@ -3641,8 +3548,7 @@ class User implements IDBAccessObject, UserIdentity { // Refresh the groups caches, and clear the rights cache so it will be // refreshed on the next call to $this->getRights(). $this->getEffectiveGroups( true ); - $this->mRights = null; - + MediaWikiServices::getInstance()->getPermissionManager()->invalidateUsersRightsCache( $this ); $this->invalidateCache(); return true; @@ -3725,16 +3631,17 @@ class User implements IDBAccessObject, UserIdentity { /** * Internal mechanics of testing a permission + * + * @deprecated since 1.34, use MediaWikiServices::getInstance() + * ->getPermissionManager()->userHasRight(...) instead + * * @param string $action + * * @return bool */ public function isAllowed( $action = '' ) { - if ( $action === '' ) { - return true; // In the spirit of DWIM - } - // Use strict parameter to avoid matching numeric 0 accidentally inserted - // by misconfiguration: 0 == 'foo' - return in_array( $action, $this->getRights(), true ); + return MediaWikiServices::getInstance()->getPermissionManager() + ->userHasRight( $this, $action ); } /** @@ -4883,45 +4790,27 @@ class User implements IDBAccessObject, UserIdentity { /** * Get the permissions associated with a given list of groups * + * @deprecated since 1.34, use MediaWikiServices::getInstance()->getPermissionManager() + * ->getGroupPermissions() instead + * * @param array $groups Array of Strings List of internal group names * @return array Array of Strings List of permission key names for given groups combined */ public static function getGroupPermissions( $groups ) { - global $wgGroupPermissions, $wgRevokePermissions; - $rights = []; - // grant every granted permission first - foreach ( $groups as $group ) { - if ( isset( $wgGroupPermissions[$group] ) ) { - $rights = array_merge( $rights, - // array_filter removes empty items - array_keys( array_filter( $wgGroupPermissions[$group] ) ) ); - } - } - // now revoke the revoked permissions - foreach ( $groups as $group ) { - if ( isset( $wgRevokePermissions[$group] ) ) { - $rights = array_diff( $rights, - array_keys( array_filter( $wgRevokePermissions[$group] ) ) ); - } - } - return array_unique( $rights ); + return MediaWikiServices::getInstance()->getPermissionManager()->getGroupPermissions( $groups ); } /** * Get all the groups who have a given permission * + * @deprecated since 1.34, use MediaWikiServices::getInstance()->getPermissionManager() + * ->getGroupsWithPermission() instead + * * @param string $role Role to check * @return array Array of Strings List of internal group names with the given permission */ public static function getGroupsWithPermission( $role ) { - global $wgGroupPermissions; - $allowedGroups = []; - foreach ( array_keys( $wgGroupPermissions ) as $group ) { - if ( self::groupHasPermission( $group, $role ) ) { - $allowedGroups[] = $group; - } - } - return $allowedGroups; + return MediaWikiServices::getInstance()->getPermissionManager()->getGroupsWithPermission( $role ); } /** @@ -4931,15 +4820,17 @@ class User implements IDBAccessObject, UserIdentity { * User::isEveryoneAllowed() instead. That properly checks if it's revoked * from anyone. * + * @deprecated since 1.34, use MediaWikiServices::getInstance()->getPermissionManager() + * ->groupHasPermission(..) instead + * * @since 1.21 * @param string $group Group to check * @param string $role Role to check * @return bool */ public static function groupHasPermission( $group, $role ) { - global $wgGroupPermissions, $wgRevokePermissions; - return isset( $wgGroupPermissions[$group][$role] ) && $wgGroupPermissions[$group][$role] - && !( isset( $wgRevokePermissions[$group][$role] ) && $wgRevokePermissions[$group][$role] ); + return MediaWikiServices::getInstance()->getPermissionManager() + ->groupHasPermission( $group, $role ); } /** @@ -4952,51 +4843,16 @@ class User implements IDBAccessObject, UserIdentity { * Specifically, session-based rights restrictions (such as OAuth or bot * passwords) are applied based on the current session. * - * @since 1.22 + * @deprecated since 1.34, use MediaWikiServices::getInstance()->getPermissionManager() + * ->isEveryoneAllowed() instead + * * @param string $right Right to check + * * @return bool + * @since 1.22 */ public static function isEveryoneAllowed( $right ) { - global $wgGroupPermissions, $wgRevokePermissions; - static $cache = []; - - // Use the cached results, except in unit tests which rely on - // being able change the permission mid-request - if ( isset( $cache[$right] ) && !defined( 'MW_PHPUNIT_TEST' ) ) { - return $cache[$right]; - } - - if ( !isset( $wgGroupPermissions['*'][$right] ) || !$wgGroupPermissions['*'][$right] ) { - $cache[$right] = false; - return false; - } - - // If it's revoked anywhere, then everyone doesn't have it - foreach ( $wgRevokePermissions as $rights ) { - if ( isset( $rights[$right] ) && $rights[$right] ) { - $cache[$right] = false; - return false; - } - } - - // Remove any rights that aren't allowed to the global-session user, - // unless there are no sessions for this endpoint. - if ( !defined( 'MW_NO_SESSION' ) ) { - $allowedRights = SessionManager::getGlobalSession()->getAllowedUserRights(); - if ( $allowedRights !== null && !in_array( $right, $allowedRights, true ) ) { - $cache[$right] = false; - return false; - } - } - - // Allow extensions to say false - if ( !Hooks::run( 'UserIsEveryoneAllowed', [ $right ] ) ) { - $cache[$right] = false; - return false; - } - - $cache[$right] = true; - return true; + return MediaWikiServices::getInstance()->getPermissionManager()->isEveryoneAllowed( $right ); } /** @@ -5015,19 +4871,14 @@ class User implements IDBAccessObject, UserIdentity { /** * Get a list of all available permissions. + * + * @deprecated since 1.34, use MediaWikiServices::getInstance()->getPermissionManager() + * ->getAllPermissions() instead + * * @return string[] Array of permission names */ public static function getAllRights() { - if ( self::$mAllRights === false ) { - global $wgAvailableRights; - if ( count( $wgAvailableRights ) ) { - self::$mAllRights = array_unique( array_merge( self::$mCoreRights, $wgAvailableRights ) ); - } else { - self::$mAllRights = self::$mCoreRights; - } - Hooks::run( 'UserGetAllRights', [ &self::$mAllRights ] ); - } - return self::$mAllRights; + return MediaWikiServices::getInstance()->getPermissionManager()->getAllPermissions(); } /** @@ -5045,10 +4896,10 @@ class User implements IDBAccessObject, UserIdentity { * Returns an array of the groups that a particular group can add/remove. * * @param string $group The group to check for whether it can add/remove - * @return array Array( 'add' => array( addablegroups ), - * 'remove' => array( removablegroups ), - * 'add-self' => array( addablegroups to self), - * 'remove-self' => array( removable groups from self) ) + * @return array [ 'add' => [ addablegroups ], + * 'remove' => [ removablegroups ], + * 'add-self' => [ addablegroups to self ], + * 'remove-self' => [ removable groups from self ] ] */ public static function changeableByGroup( $group ) { global $wgAddGroups, $wgRemoveGroups, $wgGroupsAddToSelf, $wgGroupsRemoveFromSelf; @@ -5118,10 +4969,10 @@ class User implements IDBAccessObject, UserIdentity { /** * Returns an array of groups that this user can add and remove - * @return array Array( 'add' => array( addablegroups ), - * 'remove' => array( removablegroups ), - * 'add-self' => array( addablegroups to self), - * 'remove-self' => array( removable groups from self) ) + * @return array [ 'add' => [ addablegroups ], + * 'remove' => [ removablegroups ], + * 'add-self' => [ addablegroups to self ], + * 'remove-self' => [ removable groups from self ] ] */ public function changeableGroups() { if ( $this->isAllowed( 'userrights' ) ) { @@ -5185,14 +5036,13 @@ class User implements IDBAccessObject, UserIdentity { /** * Initialize user_editcount from data out of the revision table * - * This method should not be called outside User/UserEditCountUpdate - * + * @internal This method should not be called outside User/UserEditCountUpdate + * @param IDatabase $dbr Replica database * @return int Number of edits */ - public function initEditCountInternal() { + public function initEditCountInternal( IDatabase $dbr ) { // Pull from a replica DB to be less cruel to servers // Accuracy isn't the point anyway here - $dbr = wfGetDB( DB_REPLICA ); $actorWhere = ActorMigration::newMigration()->getWhere( $dbr, 'rev_user', $this ); $count = (int)$dbr->selectField( [ 'revision' ] + $actorWhere['tables'], diff --git a/includes/user/UserGroupMembership.php b/includes/user/UserGroupMembership.php index e06df9fd23..fdac4a237b 100644 --- a/includes/user/UserGroupMembership.php +++ b/includes/user/UserGroupMembership.php @@ -159,7 +159,15 @@ class UserGroupMembership { } // Purge old, expired memberships from the DB - JobQueueGroup::singleton()->push( new UserGroupExpiryJob() ); + $hasExpiredRow = $dbw->selectField( + 'user_groups', + '1', + [ 'ug_expiry < ' . $dbw->addQuotes( $dbw->timestamp() ) ], + __METHOD__ + ); + if ( $hasExpiredRow ) { + JobQueueGroup::singleton()->lazyPush( new UserGroupExpiryJob() ); + } // Check that the values make sense if ( $this->group === null ) { @@ -248,9 +256,9 @@ class UserGroupMembership { $lbFactory = $services->getDBLoadBalancerFactory(); $ticket = $lbFactory->getEmptyTransactionTicket( __METHOD__ ); - $dbw = $services->getDBLoadBalancer()->getConnection( DB_MASTER ); + $dbw = $services->getDBLoadBalancer()->getConnectionRef( DB_MASTER ); - $lockKey = $dbw->getDomainID() . ':usergroups-prune'; // specific to this wiki + $lockKey = "{$dbw->getDomainID()}:UserGroupMembership:purge"; // per-wiki $scopedLock = $dbw->getScopedLockAndFlush( $lockKey, __METHOD__, 0 ); if ( !$scopedLock ) { return false; // already running diff --git a/includes/user/UserRightsProxy.php b/includes/user/UserRightsProxy.php index 0b7a7200f9..603d3339a8 100644 --- a/includes/user/UserRightsProxy.php +++ b/includes/user/UserRightsProxy.php @@ -30,7 +30,7 @@ class UserRightsProxy { /** @var IDatabase */ private $db; /** @var string */ - private $wikiId; + private $dbDomain; /** @var string */ private $name; /** @var int */ @@ -42,13 +42,13 @@ class UserRightsProxy { * @see newFromId() * @see newFromName() * @param IDatabase $db Db connection - * @param string $wikiId Database name + * @param string $dbDomain Database name * @param string $name User name * @param int $id User ID */ - private function __construct( $db, $wikiId, $name, $id ) { + private function __construct( $db, $dbDomain, $name, $id ) { $this->db = $db; - $this->wikiId = $wikiId; + $this->dbDomain = $dbDomain; $this->name = $name; $this->id = intval( $id ); $this->newOptions = []; @@ -57,24 +57,24 @@ class UserRightsProxy { /** * Confirm the selected database name is a valid local interwiki database name. * - * @param string $wikiId Database name + * @param string $dbDomain Database name * @return bool */ - public static function validDatabase( $wikiId ) { + public static function validDatabase( $dbDomain ) { global $wgLocalDatabases; - return in_array( $wikiId, $wgLocalDatabases ); + return in_array( $dbDomain, $wgLocalDatabases ); } /** * Same as User::whoIs() * - * @param string $wikiId Database name + * @param string $dbDomain Database name * @param int $id User ID - * @param bool $ignoreInvalidDB If true, don't check if $wikiId is in $wgLocalDatabases + * @param bool $ignoreInvalidDB If true, don't check if $dbDomain is in $wgLocalDatabases * @return string User name or false if the user doesn't exist */ - public static function whoIs( $wikiId, $id, $ignoreInvalidDB = false ) { - $user = self::newFromId( $wikiId, $id, $ignoreInvalidDB ); + public static function whoIs( $dbDomain, $id, $ignoreInvalidDB = false ) { + $user = self::newFromId( $dbDomain, $id, $ignoreInvalidDB ); if ( $user ) { return $user->name; } else { @@ -85,35 +85,35 @@ class UserRightsProxy { /** * Factory function; get a remote user entry by ID number. * - * @param string $wikiId Database name + * @param string $dbDomain Database name * @param int $id User ID - * @param bool $ignoreInvalidDB If true, don't check if $wikiId is in $wgLocalDatabases + * @param bool $ignoreInvalidDB If true, don't check if $dbDomain is in $wgLocalDatabases * @return UserRightsProxy|null If doesn't exist */ - public static function newFromId( $wikiId, $id, $ignoreInvalidDB = false ) { - return self::newFromLookup( $wikiId, 'user_id', intval( $id ), $ignoreInvalidDB ); + public static function newFromId( $dbDomain, $id, $ignoreInvalidDB = false ) { + return self::newFromLookup( $dbDomain, 'user_id', intval( $id ), $ignoreInvalidDB ); } /** * Factory function; get a remote user entry by name. * - * @param string $wikiId Database name + * @param string $dbDomain Database name * @param string $name User name - * @param bool $ignoreInvalidDB If true, don't check if $wikiId is in $wgLocalDatabases + * @param bool $ignoreInvalidDB If true, don't check if $dbDomain is in $wgLocalDatabases * @return UserRightsProxy|null If doesn't exist */ - public static function newFromName( $wikiId, $name, $ignoreInvalidDB = false ) { - return self::newFromLookup( $wikiId, 'user_name', $name, $ignoreInvalidDB ); + public static function newFromName( $dbDomain, $name, $ignoreInvalidDB = false ) { + return self::newFromLookup( $dbDomain, 'user_name', $name, $ignoreInvalidDB ); } /** - * @param string $wikiId + * @param string $dbDomain * @param string $field * @param string $value * @param bool $ignoreInvalidDB * @return null|UserRightsProxy */ - private static function newFromLookup( $wikiId, $field, $value, $ignoreInvalidDB = false ) { + private static function newFromLookup( $dbDomain, $field, $value, $ignoreInvalidDB = false ) { global $wgSharedDB, $wgSharedTables; // If the user table is shared, perform the user query on it, // but don't pass it to the UserRightsProxy, @@ -121,10 +121,10 @@ class UserRightsProxy { if ( $wgSharedDB && in_array( 'user', $wgSharedTables ) ) { $userdb = self::getDB( $wgSharedDB, $ignoreInvalidDB ); } else { - $userdb = self::getDB( $wikiId, $ignoreInvalidDB ); + $userdb = self::getDB( $dbDomain, $ignoreInvalidDB ); } - $db = self::getDB( $wikiId, $ignoreInvalidDB ); + $db = self::getDB( $dbDomain, $ignoreInvalidDB ); if ( $db && $userdb ) { $row = $userdb->selectRow( 'user', @@ -134,7 +134,7 @@ class UserRightsProxy { if ( $row !== false ) { return new UserRightsProxy( - $db, $wikiId, $row->user_name, intval( $row->user_id ) ); + $db, $dbDomain, $row->user_name, intval( $row->user_id ) ); } } return null; @@ -144,17 +144,17 @@ class UserRightsProxy { * Open a database connection to work on for the requested user. * This may be a new connection to another database for remote users. * - * @param string $wikiId - * @param bool $ignoreInvalidDB If true, don't check if $wikiId is in $wgLocalDatabases + * @param string $dbDomain + * @param bool $ignoreInvalidDB If true, don't check if $dbDomain is in $wgLocalDatabases * @return IDatabase|null If invalid selection */ - public static function getDB( $wikiId, $ignoreInvalidDB = false ) { - if ( $ignoreInvalidDB || self::validDatabase( $wikiId ) ) { - if ( WikiMap::isCurrentWikiId( $wikiId ) ) { + public static function getDB( $dbDomain, $ignoreInvalidDB = false ) { + if ( $ignoreInvalidDB || self::validDatabase( $dbDomain ) ) { + if ( WikiMap::isCurrentWikiId( $dbDomain ) ) { // Hmm... this shouldn't happen though. :) return wfGetDB( DB_MASTER ); } else { - return wfGetDB( DB_MASTER, [], $wikiId ); + return wfGetDB( DB_MASTER, [], $dbDomain ); } } return null; @@ -180,7 +180,7 @@ class UserRightsProxy { * @return string */ public function getName() { - return $this->name . '@' . $this->wikiId; + return $this->name . '@' . $this->dbDomain; } /** diff --git a/includes/utils/ClassCollector.php b/includes/utils/ClassCollector.php index c987354d37..12b8a707bd 100644 --- a/includes/utils/ClassCollector.php +++ b/includes/utils/ClassCollector.php @@ -59,6 +59,31 @@ class ClassCollector { $this->alias = null; $this->tokens = []; + // HACK: The PHP tokenizer is slow (T225730). + // Speed it up by reducing the input to the three kinds of statement we care about: + // - namespace X; + // - [final] [abstract] class X … {} + // - class_alias( … ); + $lines = []; + $matches = null; + preg_match_all( + // phpcs:ignore Generic.Files.LineLength.TooLong + '#^\t*(?:namespace |(final )?(abstract )?(class|interface|trait) |class_alias\()[^;{]+[;{]\s*\}?#m', + $code, + $matches + ); + if ( isset( $matches[0][0] ) ) { + foreach ( $matches[0] as $match ) { + $match = trim( $match ); + if ( substr( $match, -1 ) === '{' ) { + // Keep it balanced + $match .= '}'; + } + $lines[] = $match; + } + } + $code = 'startToken === null ) { $this->tryBeginExpect( $token ); diff --git a/includes/watcheditem/WatchedItemQueryService.php b/includes/watcheditem/WatchedItemQueryService.php index b134bfe13c..df5ea70441 100644 --- a/includes/watcheditem/WatchedItemQueryService.php +++ b/includes/watcheditem/WatchedItemQueryService.php @@ -1,10 +1,11 @@ isAllowed( 'deletedhistory' ) ) { - $bitmask = Revision::DELETED_USER; + $bitmask = RevisionRecord::DELETED_USER; } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) { - $bitmask = Revision::DELETED_USER | Revision::DELETED_RESTRICTED; + $bitmask = RevisionRecord::DELETED_USER | RevisionRecord::DELETED_RESTRICTED; } if ( $bitmask ) { $conds[] = $db->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask"; diff --git a/includes/watcheditem/WatchedItemStore.php b/includes/watcheditem/WatchedItemStore.php index 0b73fe34db..c3630292fb 100644 --- a/includes/watcheditem/WatchedItemStore.php +++ b/includes/watcheditem/WatchedItemStore.php @@ -1120,6 +1120,11 @@ class WatchedItemStore implements WatchedItemStoreInterface, StatsdAwareInterfac } $oldRev = $this->revisionLookup->getRevisionById( $oldid ); + if ( !$oldRev ) { + // Oldid given but does not exist (probably deleted) + return false; + } + $nextRev = $this->revisionLookup->getNextRevision( $oldRev ); if ( !$nextRev ) { // Oldid given and is the latest revision for this title; clear the timestamp. diff --git a/includes/widget/search/BasicSearchResultSetWidget.php b/includes/widget/search/BasicSearchResultSetWidget.php index 1a885b0136..698d22315b 100644 --- a/includes/widget/search/BasicSearchResultSetWidget.php +++ b/includes/widget/search/BasicSearchResultSetWidget.php @@ -2,9 +2,9 @@ namespace MediaWiki\Widget\Search; +use ISearchResultSet; use MediaWiki\MediaWikiServices; use Message; -use SearchResultSet; use SpecialSearch; use Status; @@ -33,23 +33,23 @@ class BasicSearchResultSetWidget { /** * @param string $term The search term to highlight * @param int $offset The offset of the first result in the result set - * @param SearchResultSet|null $titleResultSet Results of searching only page titles - * @param SearchResultSet|null $textResultSet Results of general full text search. + * @param ISearchResultSet|null $titleResultSet Results of searching only page titles + * @param ISearchResultSet|null $textResultSet Results of general full text search. * @return string HTML */ public function render( $term, $offset, - SearchResultSet $titleResultSet = null, - SearchResultSet $textResultSet = null + ISearchResultSet $titleResultSet = null, + ISearchResultSet $textResultSet = null ) { $hasTitle = $titleResultSet ? $titleResultSet->numRows() > 0 : false; $hasText = $textResultSet ? $textResultSet->numRows() > 0 : false; $hasSecondary = $textResultSet - ? $textResultSet->hasInterwikiResults( SearchResultSet::SECONDARY_RESULTS ) + ? $textResultSet->hasInterwikiResults( ISearchResultSet::SECONDARY_RESULTS ) : false; $hasSecondaryInline = $textResultSet - ? $textResultSet->hasInterwikiResults( SearchResultSet::INLINE_RESULTS ) + ? $textResultSet->hasInterwikiResults( ISearchResultSet::INLINE_RESULTS ) : false; if ( !$hasTitle && !$hasText && !$hasSecondary && !$hasSecondaryInline ) { @@ -71,7 +71,7 @@ class BasicSearchResultSetWidget { } if ( $hasSecondaryInline ) { - $iwResults = $textResultSet->getInterwikiResults( SearchResultSet::INLINE_RESULTS ); + $iwResults = $textResultSet->getInterwikiResults( ISearchResultSet::INLINE_RESULTS ); foreach ( $iwResults as $interwiki => $results ) { if ( $results instanceof Status || $results->numRows() === 0 ) { // ignore bad interwikis for now @@ -88,7 +88,7 @@ class BasicSearchResultSetWidget { if ( $hasSecondary ) { $out .= $this->sidebarWidget->render( $term, - $textResultSet->getInterwikiResults( SearchResultSet::SECONDARY_RESULTS ) + $textResultSet->getInterwikiResults( ISearchResultSet::SECONDARY_RESULTS ) ); } @@ -112,17 +112,14 @@ class BasicSearchResultSetWidget { } /** - * @param SearchResultSet $resultSet The search results to render + * @param ISearchResultSet $resultSet The search results to render * @param int $offset Offset of the first result in $resultSet * @return string HTML */ - protected function renderResultSet( SearchResultSet $resultSet, $offset ) { - $terms = MediaWikiServices::getInstance()->getContentLanguage()-> - convertForSearchResult( $resultSet->termMatches() ); - + protected function renderResultSet( ISearchResultSet $resultSet, $offset ) { $hits = []; foreach ( $resultSet as $result ) { - $hits[] = $this->resultWidget->render( $result, $terms, $offset++ ); + $hits[] = $this->resultWidget->render( $result, $offset++ ); } return "
    " . implode( '', $hits ) . "
"; diff --git a/includes/widget/search/DidYouMeanWidget.php b/includes/widget/search/DidYouMeanWidget.php index 135b01d2aa..a8f57e2b62 100644 --- a/includes/widget/search/DidYouMeanWidget.php +++ b/includes/widget/search/DidYouMeanWidget.php @@ -3,7 +3,7 @@ namespace MediaWiki\Widget\Search; use HtmlArmor; -use SearchResultSet; +use ISearchResultSet; use SpecialSearch; /** @@ -20,10 +20,10 @@ class DidYouMeanWidget { /** * @param string $term The user provided search term - * @param SearchResultSet $resultSet + * @param ISearchResultSet $resultSet * @return string HTML */ - public function render( $term, SearchResultSet $resultSet ) { + public function render( $term, ISearchResultSet $resultSet ) { if ( $resultSet->hasRewrittenQuery() ) { $html = $this->rewrittenHtml( $term, $resultSet ); } elseif ( $resultSet->hasSuggestion() ) { @@ -40,11 +40,11 @@ class DidYouMeanWidget { * rewritten, and the results of the rewritten query are being returned. * * @param string $term The users search input - * @param SearchResultSet $resultSet The response to the search request + * @param ISearchResultSet $resultSet The response to the search request * @return string HTML Links the user to their original $term query, and the * one suggested by $resultSet */ - protected function rewrittenHtml( $term, SearchResultSet $resultSet ) { + protected function rewrittenHtml( $term, ISearchResultSet $resultSet ) { $params = [ 'search' => $resultSet->getQueryAfterRewrite(), // Don't magic this link into a 'go' link, it should always @@ -81,10 +81,10 @@ class DidYouMeanWidget { * a query that might give more/better results than their current * query. * - * @param SearchResultSet $resultSet + * @param ISearchResultSet $resultSet * @return string HTML */ - protected function suggestionHtml( SearchResultSet $resultSet ) { + protected function suggestionHtml( ISearchResultSet $resultSet ) { $params = [ 'search' => $resultSet->getSuggestionQuery(), 'fulltext' => 1, diff --git a/includes/widget/search/FullSearchResultWidget.php b/includes/widget/search/FullSearchResultWidget.php index d70057091e..7212dc0498 100644 --- a/includes/widget/search/FullSearchResultWidget.php +++ b/includes/widget/search/FullSearchResultWidget.php @@ -31,11 +31,10 @@ class FullSearchResultWidget implements SearchResultWidget { /** * @param SearchResult $result The result to render - * @param string $terms Terms to be highlighted (@see SearchResult::getTextSnippet) * @param int $position The result position, including offset * @return string HTML */ - public function render( SearchResult $result, $terms, $position ) { + public function render( SearchResult $result, $position ) { // If the page doesn't *exist*... our search index is out of date. // The least confusing at this point is to drop the result. // You may get less results, but... on well. :P @@ -43,12 +42,15 @@ class FullSearchResultWidget implements SearchResultWidget { return ''; } - $link = $this->generateMainLinkHtml( $result, $terms, $position ); + $link = $this->generateMainLinkHtml( $result, $position ); // If page content is not readable, just return ths title. // This is not quite safe, but better than showing excerpts from // non-readable pages. Note that hiding the entry entirely would // screw up paging (really?). - if ( !$result->getTitle()->userCan( 'read', $this->specialPage->getUser() ) ) { + $permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); + if ( !$permissionManager->userCan( + 'read', $this->specialPage->getUser(), $result->getTitle() + ) ) { return "
  • {$link}
  • "; } @@ -60,7 +62,7 @@ class FullSearchResultWidget implements SearchResultWidget { $this->specialPage->getUser() ); list( $file, $desc, $thumb ) = $this->generateFileHtml( $result ); - $snippet = $result->getTextSnippet( $terms ); + $snippet = $result->getTextSnippet(); if ( $snippet ) { $extract = "
    $snippet
    "; } else { @@ -77,6 +79,9 @@ class FullSearchResultWidget implements SearchResultWidget { $html = null; $score = ''; $related = ''; + // TODO: remove this instanceof and always pass [], let implementors do the cast if + // they want to be SearchDatabase specific + $terms = $result instanceof \SqlSearchResult ? $result->getTermMatches() : []; if ( !Hooks::run( 'ShowSearchHit', [ $this->specialPage, $result, $terms, &$link, &$redirect, &$section, &$extract, @@ -118,11 +123,10 @@ class FullSearchResultWidget implements SearchResultWidget { * title with highlighted words). * * @param SearchResult $result - * @param string $terms * @param int $position * @return string HTML */ - protected function generateMainLinkHtml( SearchResult $result, $terms, $position ) { + protected function generateMainLinkHtml( SearchResult $result, $position ) { $snippet = $result->getTitleSnippet(); if ( $snippet === '' ) { $snippet = null; @@ -136,7 +140,9 @@ class FullSearchResultWidget implements SearchResultWidget { $attributes = [ 'data-serp-pos' => $position ]; Hooks::run( 'ShowSearchHitTitle', - [ &$title, &$snippet, $result, $terms, $this->specialPage, &$query, &$attributes ] ); + [ &$title, &$snippet, $result, + $result instanceof \SqlSearchResult ? $result->getTermMatches() : [], + $this->specialPage, &$query, &$attributes ] ); $link = $this->linkRenderer->makeLink( $title, diff --git a/includes/widget/search/InterwikiSearchResultSetWidget.php b/includes/widget/search/InterwikiSearchResultSetWidget.php index 48c624c0f7..c495aa52c8 100644 --- a/includes/widget/search/InterwikiSearchResultSetWidget.php +++ b/includes/widget/search/InterwikiSearchResultSetWidget.php @@ -4,14 +4,14 @@ namespace MediaWiki\Widget\Search; use MediaWiki\Interwiki\InterwikiLookup; use MediaWiki\Linker\LinkRenderer; -use SearchResultSet; +use ISearchResultSet; use SpecialSearch; use Title; use Html; use OOUI; /** - * Renders one or more SearchResultSets into a sidebar grouped by + * Renders one or more ISearchResultSets into a sidebar grouped by * interwiki prefix. Includes a per-wiki header indicating where * the results are from. */ @@ -48,7 +48,7 @@ class InterwikiSearchResultSetWidget implements SearchResultSetWidget { /** * @param string $term User provided search term - * @param SearchResultSet|SearchResultSet[] $resultSets List of interwiki + * @param ISearchResultSet|ISearchResultSet[] $resultSets List of interwiki * results to render. * @return string HTML */ @@ -82,7 +82,7 @@ class InterwikiSearchResultSetWidget implements SearchResultSetWidget { $iwResultItemOutput = ''; foreach ( $results as $result ) { - $iwResultItemOutput .= $this->resultWidget->render( $result, $term, $position++ ); + $iwResultItemOutput .= $this->resultWidget->render( $result, $position++ ); } $footerHtml = $this->footerHtml( $term, $iwPrefix ); diff --git a/includes/widget/search/InterwikiSearchResultWidget.php b/includes/widget/search/InterwikiSearchResultWidget.php index 095c30a629..3f758db5bb 100644 --- a/includes/widget/search/InterwikiSearchResultWidget.php +++ b/includes/widget/search/InterwikiSearchResultWidget.php @@ -24,14 +24,13 @@ class InterwikiSearchResultWidget implements SearchResultWidget { /** * @param SearchResult $result The result to render - * @param string $terms Terms to be highlighted (@see SearchResult::getTextSnippet) * @param int $position The result position, including offset * @return string HTML */ - public function render( SearchResult $result, $terms, $position ) { + public function render( SearchResult $result, $position ) { $title = $result->getTitle(); $titleSnippet = $result->getTitleSnippet(); - $snippet = $result->getTextSnippet( $terms ); + $snippet = $result->getTextSnippet(); if ( $titleSnippet ) { $titleSnippet = new HtmlArmor( $titleSnippet ); diff --git a/includes/widget/search/SearchResultSetWidget.php b/includes/widget/search/SearchResultSetWidget.php index 6df6e65c2a..ee9142e407 100644 --- a/includes/widget/search/SearchResultSetWidget.php +++ b/includes/widget/search/SearchResultSetWidget.php @@ -2,7 +2,7 @@ namespace MediaWiki\Widget\Search; -use SearchResultSet; +use ISearchResultSet; /** * Renders a set of search results to HTML @@ -10,7 +10,7 @@ use SearchResultSet; interface SearchResultSetWidget { /** * @param string $term User provided search term - * @param SearchResultSet|SearchResultSet[] $resultSets List of interwiki + * @param ISearchResultSet|ISearchResultSet[] $resultSets List of interwiki * results to render. * @return string HTML */ diff --git a/includes/widget/search/SearchResultWidget.php b/includes/widget/search/SearchResultWidget.php index 3fbdbef2a9..e001395541 100644 --- a/includes/widget/search/SearchResultWidget.php +++ b/includes/widget/search/SearchResultWidget.php @@ -10,9 +10,8 @@ use SearchResult; interface SearchResultWidget { /** * @param SearchResult $result The result to render - * @param string $terms Terms to be highlighted (@see SearchResult::getTextSnippet) * @param int $position The zero indexed result position, including offset * @return string HTML */ - public function render( SearchResult $result, $terms, $position ); + public function render( SearchResult $result, $position ); } diff --git a/includes/widget/search/SimpleSearchResultSetWidget.php b/includes/widget/search/SimpleSearchResultSetWidget.php index 248099ada0..12bc4b27de 100644 --- a/includes/widget/search/SimpleSearchResultSetWidget.php +++ b/includes/widget/search/SimpleSearchResultSetWidget.php @@ -4,13 +4,13 @@ namespace MediaWiki\Widget\Search; use MediaWiki\Interwiki\InterwikiLookup; use MediaWiki\Linker\LinkRenderer; -use SearchResultSet; +use ISearchResultSet; use SpecialSearch; use Title; use Html; /** - * Renders one or more SearchResultSets into a sidebar grouped by + * Renders one or more ISearchResultSets into a sidebar grouped by * interwiki prefix. Includes a per-wiki header indicating where * the results are from. * @@ -43,7 +43,7 @@ class SimpleSearchResultSetWidget implements SearchResultSetWidget { /** * @param string $term User provided search term - * @param SearchResultSet|SearchResultSet[] $resultSets List of interwiki + * @param ISearchResultSet|ISearchResultSet[] $resultSets List of interwiki * results to render. * @return string HTML */ diff --git a/includes/widget/search/SimpleSearchResultWidget.php b/includes/widget/search/SimpleSearchResultWidget.php index 552cbaf8ba..fe8b4d5092 100644 --- a/includes/widget/search/SimpleSearchResultWidget.php +++ b/includes/widget/search/SimpleSearchResultWidget.php @@ -26,11 +26,10 @@ class SimpleSearchResultWidget implements SearchResultWidget { /** * @param SearchResult $result The result to render - * @param string $terms Terms to be highlighted (@see SearchResult::getTextSnippet) * @param int $position The result position, including offset * @return string HTML */ - public function render( SearchResult $result, $terms, $position ) { + public function render( SearchResult $result, $position ) { $title = $result->getTitle(); $titleSnippet = $result->getTitleSnippet(); if ( $titleSnippet ) { diff --git a/languages/Language.php b/languages/Language.php index 4e663c250c..bb256c9c99 100644 --- a/languages/Language.php +++ b/languages/Language.php @@ -2967,8 +2967,8 @@ class Language { } /** - * @param array $termsArray - * @return array + * @param string[] $termsArray + * @return string[] */ function convertForSearchResult( $termsArray ) { # some languages, e.g. Chinese, need to do a conversion @@ -4537,7 +4537,7 @@ class Language { * * @since 1.22 * @param string $code Language code - * @return array Array( fallbacks, site fallbacks ) + * @return array [ fallbacks, site fallbacks ] */ public static function getFallbacksIncludingSiteLanguage( $code ) { global $wgLanguageCode; @@ -4863,6 +4863,7 @@ class Language { public function viewPrevNext( Title $title, $offset, $limit, array $query = [], $atend = false ) { + wfDeprecated( __METHOD__, '1.34' ); // @todo FIXME: Why on earth this needs one message for the text and another one for tooltip? # Make 'previous' link diff --git a/languages/LanguageCode.php b/languages/LanguageCode.php deleted file mode 100644 index 7d954d3803..0000000000 --- a/languages/LanguageCode.php +++ /dev/null @@ -1,204 +0,0 @@ - 'gsw', // T25215 - 'bat-smg' => 'sgs', // T27522 - 'be-x-old' => 'be-tarask', // T11823 - 'fiu-vro' => 'vro', // T31186 - 'roa-rup' => 'rup', // T17988 - 'zh-classical' => 'lzh', // T30443 - 'zh-min-nan' => 'nan', // T30442 - 'zh-yue' => 'yue', // T30441 - ]; - - /** - * Mapping of non-standard language codes used in MediaWiki to - * standardized BCP 47 codes. These are not deprecated (yet?): - * IANA may eventually recognize the subtag, in which case the `-x-` - * infix could be removed, or else we could rename the code in - * MediaWiki, in which case they'd move up to the above mapping - * of deprecated codes. - * - * As a rule, we preserve all distinctions made by MediaWiki - * internally. For example, `de-formal` becomes `de-x-formal` - * instead of just `de` because MediaWiki distinguishes `de-formal` - * from `de` (for example, for interface translations). Similarly, - * BCP 47 indicates that `kk-Cyrl` SHOULD not be used because it - * "typically does not add information", but in our case MediaWiki - * LanguageConverter distinguishes `kk` (render content in a mix of - * Kurdish variants) from `kk-Cyrl` (convert content to be uniformly - * Cyrillic). As the BCP 47 requirement is a SHOULD not a MUST, - * `kk-Cyrl` is a valid code, although some validators may emit - * a warning note. - * - * @var array Mapping from nonstandard MediaWiki-internal codes to - * BCP 47 codes - * - * @since 1.32 - * @see https://meta.wikimedia.org/wiki/Special_language_codes - * @see https://phabricator.wikimedia.org/T125073 - */ - private static $nonstandardLanguageCodeMapping = [ - // All codes returned by Language::fetchLanguageNames() validated - // against IANA registry at - // https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry - // with help of validator at - // http://schneegans.de/lv/ - 'cbk-zam' => 'cbk', // T124657 - 'de-formal' => 'de-x-formal', - 'eml' => 'egl', // T36217 - 'en-rtl' => 'en-x-rtl', - 'es-formal' => 'es-x-formal', - 'hu-formal' => 'hu-x-formal', - 'map-bms' => 'jv-x-bms', // [[en:Banyumasan_dialect]] T125073 - 'mo' => 'ro-Cyrl-MD', // T125073 - 'nrm' => 'nrf', // [[en:Norman_language]] T25216 - 'nl-informal' => 'nl-x-informal', - 'roa-tara' => 'nap-x-tara', // [[en:Tarantino_dialect]] - 'simple' => 'en-simple', - 'sr-ec' => 'sr-Cyrl', // T117845 - 'sr-el' => 'sr-Latn', // T117845 - - // Although these next codes aren't *wrong* per se, including - // both the script and the country code helps compatibility with - // other BCP 47 users. Note that MW also uses `zh-Hans`/`zh-Hant`, - // without a country code, and those should be left alone. - // (See $variantfallbacks in LanguageZh.php for Hans/Hant id.) - 'zh-cn' => 'zh-Hans-CN', - 'zh-sg' => 'zh-Hans-SG', - 'zh-my' => 'zh-Hans-MY', - 'zh-tw' => 'zh-Hant-TW', - 'zh-hk' => 'zh-Hant-HK', - 'zh-mo' => 'zh-Hant-MO', - ]; - - /** - * Returns a mapping of deprecated language codes that were used in previous - * versions of MediaWiki to up-to-date, current language codes. - * - * This array is merged into $wgDummyLanguageCodes in Setup.php, along with - * the fake language codes 'qqq' and 'qqx', which are used internally by - * MediaWiki's localisation system. - * - * @return string[] - * - * @since 1.29 - */ - public static function getDeprecatedCodeMapping() { - return self::$deprecatedLanguageCodeMapping; - } - - /** - * Returns a mapping of non-standard language codes used by - * (current and previous version of) MediaWiki, mapped to standard - * BCP 47 names. - * - * This array is exported to JavaScript to ensure - * mediawiki.language.bcp47 stays in sync with LanguageCode::bcp47(). - * - * @return string[] - * - * @since 1.32 - */ - public static function getNonstandardLanguageCodeMapping() { - $result = []; - foreach ( self::$deprecatedLanguageCodeMapping as $code => $ignore ) { - $result[$code] = self::bcp47( $code ); - } - foreach ( self::$nonstandardLanguageCodeMapping as $code => $ignore ) { - $result[$code] = self::bcp47( $code ); - } - return $result; - } - - /** - * Replace deprecated language codes that were used in previous - * versions of MediaWiki to up-to-date, current language codes. - * Other values will returned unchanged. - * - * @param string $code Old language code - * @return string New language code - * - * @since 1.30 - */ - public static function replaceDeprecatedCodes( $code ) { - return self::$deprecatedLanguageCodeMapping[$code] ?? $code; - } - - /** - * Get the normalised IETF language tag - * See unit test for examples. - * See mediawiki.language.bcp47 for the JavaScript implementation. - * - * @param string $code The language code. - * @return string A language code complying with BCP 47 standards. - * - * @since 1.31 - */ - public static function bcp47( $code ) { - $code = self::replaceDeprecatedCodes( strtolower( $code ) ); - if ( isset( self::$nonstandardLanguageCodeMapping[$code] ) ) { - $code = self::$nonstandardLanguageCodeMapping[$code]; - } - $codeSegment = explode( '-', $code ); - $codeBCP = []; - foreach ( $codeSegment as $segNo => $seg ) { - // when previous segment is x, it is a private segment and should be lc - if ( $segNo > 0 && strtolower( $codeSegment[( $segNo - 1 )] ) == 'x' ) { - $codeBCP[$segNo] = strtolower( $seg ); - // ISO 3166 country code - } elseif ( ( strlen( $seg ) == 2 ) && ( $segNo > 0 ) ) { - $codeBCP[$segNo] = strtoupper( $seg ); - // ISO 15924 script code - } elseif ( ( strlen( $seg ) == 4 ) && ( $segNo > 0 ) ) { - $codeBCP[$segNo] = ucfirst( strtolower( $seg ) ); - // Use lowercase for other cases - } else { - $codeBCP[$segNo] = strtolower( $seg ); - } - } - $langCode = implode( '-', $codeBCP ); - return $langCode; - } -} diff --git a/languages/LanguageConverter.php b/languages/LanguageConverter.php index c5ff9d65a7..61a967dc46 100644 --- a/languages/LanguageConverter.php +++ b/languages/LanguageConverter.php @@ -21,6 +21,7 @@ use MediaWiki\MediaWikiServices; use MediaWiki\Logger\LoggerFactory; +use MediaWiki\Storage\RevisionRecord; /** * Base class for language conversion. @@ -391,27 +392,30 @@ class LanguageConverter { IMPORTANT: Beware of failure from pcre.backtrack_limit (T124404). Minimize use of backtracking where possible. */ - $marker = '|' . Parser::MARKER_PREFIX . '[^\x7f]++\x7f'; - - // this one is needed when the text is inside an HTML markup - $htmlfix = '|<[^>\004]++(?=\004$)|^[^<>]*+>'; - - // Optimize for the common case where these tags have - // few or no children. Thus try and possesively get as much as - // possible, and only engage in backtracking when we hit a '<'. - - // disable convert to variants between tags - $codefix = '[^<]*+(?:(?:(?!<\/code>).)[^<]*+)*+<\/code>|'; - // disable conversion of " ], // Multiple only=styles load [ [ [ 'test.baz', 'test.foo', 'test.bar' ], ResourceLoaderModule::TYPE_STYLES ], - '' + '' ], // Private embed (only=scripts) [ @@ -2747,14 +2618,14 @@ class OutputPageTest extends MediaWikiTestCase { // noscript group [ [ 'test.noscript', ResourceLoaderModule::TYPE_STYLES ], - '' + '' ], // Load two modules in separate groups [ [ [ 'test.group.foo', 'test.group.bar' ], ResourceLoaderModule::TYPE_COMBINED ], "" ], ]; @@ -2784,17 +2655,16 @@ class OutputPageTest extends MediaWikiTestCase { ->setConstructorArgs( [ $ctx ] ) ->setMethods( [ 'buildCssLinksArray' ] ) ->getMock(); - $op->expects( $this->any() ) - ->method( 'buildCssLinksArray' ) + $op->method( 'buildCssLinksArray' ) ->willReturn( [] ); $rl = $op->getResourceLoader(); $rl->setMessageBlobStore( $this->createMock( MessageBlobStore::class ) ); // Register custom modules $rl->register( [ - 'example.site.a' => new ResourceLoaderTestModule( [ 'group' => 'site' ] ), - 'example.site.b' => new ResourceLoaderTestModule( [ 'group' => 'site' ] ), - 'example.user' => new ResourceLoaderTestModule( [ 'group' => 'user' ] ), + 'example.site.a' => [ 'class' => ResourceLoaderTestModule::class, 'group' => 'site' ], + 'example.site.b' => [ 'class' => ResourceLoaderTestModule::class, 'group' => 'site' ], + 'example.user' => [ 'class' => ResourceLoaderTestModule::class, 'group' => 'user' ], ] ); $op = TestingAccessWrapper::newFromObject( $op ); @@ -2810,22 +2680,22 @@ class OutputPageTest extends MediaWikiTestCase { return [ 'empty' => [ 'exemptStyleModules' => [], - '', + '', ], 'empty sets' => [ 'exemptStyleModules' => [ 'site' => [], 'noscript' => [], 'private' => [], 'user' => [] ], - '', + '', ], 'default logged-out' => [ 'exemptStyleModules' => [ 'site' => [ 'site.styles' ] ], '' . "\n" . - '', + '', ], 'default logged-in' => [ 'exemptStyleModules' => [ 'site' => [ 'site.styles' ], 'user' => [ 'user.styles' ] ], '' . "\n" . - '' . "\n" . - '', + '' . "\n" . + '', ], 'custom modules' => [ 'exemptStyleModules' => [ @@ -2833,10 +2703,10 @@ class OutputPageTest extends MediaWikiTestCase { 'user' => [ 'user.styles', 'example.user' ], ], '' . "\n" . - '' . "\n" . - '' . "\n" . - '' . "\n" . - '', + '' . "\n" . + '' . "\n" . + '' . "\n" . + '', ], ]; // phpcs:enable diff --git a/tests/phpunit/includes/PathRouterTest.php b/tests/phpunit/includes/PathRouterTest.php deleted file mode 100644 index d8916751c0..0000000000 --- a/tests/phpunit/includes/PathRouterTest.php +++ /dev/null @@ -1,325 +0,0 @@ -add( "/wiki/$1" ); - $this->basicRouter = $router; - } - - public static function provideParse() { - $tests = [ - // Basic path parsing - 'Basic path parsing' => [ - "/wiki/$1", - "/wiki/Foo", - [ 'title' => "Foo" ] - ], - // - 'Loose path auto-$1: /$1' => [ - "/", - "/Foo", - [ 'title' => "Foo" ] - ], - 'Loose path auto-$1: /wiki' => [ - "/wiki", - "/wiki/Foo", - [ 'title' => "Foo" ] - ], - 'Loose path auto-$1: /wiki/' => [ - "/wiki/", - "/wiki/Foo", - [ 'title' => "Foo" ] - ], - // Ensure that path is based on specificity, not order - 'Order, /$1 added first' => [ - [ "/$1", "/a/$1", "/b/$1" ], - "/a/Foo", - [ 'title' => "Foo" ] - ], - 'Order, /$1 added last' => [ - [ "/b/$1", "/a/$1", "/$1" ], - "/a/Foo", - [ 'title' => "Foo" ] - ], - // Handling of key based arrays with a url parameter - 'Key based array' => [ - [ [ - 'path' => [ 'edit' => "/edit/$1" ], - 'params' => [ 'action' => '$key' ], - ] ], - "/edit/Foo", - [ 'title' => "Foo", 'action' => 'edit' ] - ], - // Additional parameter - 'Basic $2' => [ - [ [ - 'path' => '/$2/$1', - 'params' => [ 'test' => '$2' ] - ] ], - "/asdf/Foo", - [ 'title' => "Foo", 'test' => 'asdf' ] - ], - ]; - // Shared patterns for restricted value parameter tests - $restrictedPatterns = [ - [ - 'path' => '/$2/$1', - 'params' => [ 'test' => '$2' ], - 'options' => [ '$2' => [ 'a', 'b' ] ] - ], - [ - 'path' => '/$2/$1', - 'params' => [ 'test2' => '$2' ], - 'options' => [ '$2' => 'c' ] - ], - '/$1' - ]; - $tests += [ - // Restricted value parameter tests - 'Restricted 1' => [ - $restrictedPatterns, - "/asdf/Foo", - [ 'title' => "asdf/Foo" ] - ], - 'Restricted 2' => [ - $restrictedPatterns, - "/a/Foo", - [ 'title' => "Foo", 'test' => 'a' ] - ], - 'Restricted 3' => [ - $restrictedPatterns, - "/c/Foo", - [ 'title' => "Foo", 'test2' => 'c' ] - ], - - // Callback test - 'Callback' => [ - [ [ - 'path' => "/$1", - 'params' => [ 'a' => 'b', 'data:foo' => 'bar' ], - 'options' => [ 'callback' => [ __CLASS__, 'callbackForTest' ] ] - ] ], - '/Foo', - [ - 'title' => "Foo", - 'x' => 'Foo', - 'a' => 'b', - 'foo' => 'bar' - ] - ], - - // Test to ensure that matches are not made if a parameter expects nonexistent input - 'Fail' => [ - [ [ - 'path' => "/wiki/$1", - 'params' => [ 'title' => "$1$2" ], - ] ], - "/wiki/A", - [] - ], - - // Make sure the router handles titles like Special:Recentchanges correctly - 'Special title' => [ - "/wiki/$1", - "/wiki/Special:Recentchanges", - [ 'title' => "Special:Recentchanges" ] - ], - - // Make sure the router decodes urlencoding properly - 'URL encoding' => [ - "/wiki/$1", - "/wiki/Title_With%20Space", - [ 'title' => "Title_With Space" ] - ], - - // Double slash and dot expansion - 'Double slash in prefix' => [ - '/wiki/$1', - '//wiki/Foo', - [ 'title' => 'Foo' ] - ], - 'Double slash at start of $1' => [ - '/wiki/$1', - '/wiki//Foo', - [ 'title' => '/Foo' ] - ], - 'Double slash in middle of $1' => [ - '/wiki/$1', - '/wiki/.hack//SIGN', - [ 'title' => '.hack//SIGN' ] - ], - 'Dots removed 1' => [ - '/wiki/$1', - '/x/../wiki/Foo', - [ 'title' => 'Foo' ] - ], - 'Dots removed 2' => [ - '/wiki/$1', - '/./wiki/Foo', - [ 'title' => 'Foo' ] - ], - 'Dots retained 1' => [ - '/wiki/$1', - '/wiki/../wiki/Foo', - [ 'title' => '../wiki/Foo' ] - ], - 'Dots retained 2' => [ - '/wiki/$1', - '/wiki/./Foo', - [ 'title' => './Foo' ] - ], - 'Triple slash' => [ - '/wiki/$1', - '///wiki/Foo', - [ 'title' => 'Foo' ] - ], - // '..' only traverses one slash, see e.g. RFC 3986 - 'Dots traversing double slash 1' => [ - '/wiki/$1', - '/a//b/../../wiki/Foo', - [] - ], - 'Dots traversing double slash 2' => [ - '/wiki/$1', - '/a//b/../../../wiki/Foo', - [ 'title' => 'Foo' ] - ], - ]; - - // Make sure the router doesn't break on special characters like $ used in regexp replacements - foreach ( [ "$", "$1", "\\", "\\$1" ] as $char ) { - $tests["Regexp character $char"] = [ - "/wiki/$1", - "/wiki/$char", - [ 'title' => "$char" ] - ]; - } - - $tests += [ - // Make sure the router handles characters like +&() properly - "Special characters" => [ - "/wiki/$1", - "/wiki/Plus+And&Dollar\\Stuff();[]{}*", - [ 'title' => "Plus+And&Dollar\\Stuff();[]{}*" ], - ], - - // Make sure the router handles unicode characters correctly - "Unicode 1" => [ - "/wiki/$1", - "/wiki/Spécial:Modifications_récentes" , - [ 'title' => "Spécial:Modifications_récentes" ], - ], - - "Unicode 2" => [ - "/wiki/$1", - "/wiki/Sp%C3%A9cial:Modifications_r%C3%A9centes", - [ 'title' => "Spécial:Modifications_récentes" ], - ] - ]; - - // Ensure the router doesn't choke on long paths. - $lorem = "Lorem_ipsum_dolor_sit_amet,_consectetur_adipisicing_elit,_sed_do_eiusmod_" . - "tempor_incididunt_ut_labore_et_dolore_magna_aliqua._Ut_enim_ad_minim_veniam,_quis_" . - "nostrud_exercitation_ullamco_laboris_nisi_ut_aliquip_ex_ea_commodo_consequat._" . - "Duis_aute_irure_dolor_in_reprehenderit_in_voluptate_velit_esse_cillum_dolore_" . - "eu_fugiat_nulla_pariatur._Excepteur_sint_occaecat_cupidatat_non_proident,_sunt_" . - "in_culpa_qui_officia_deserunt_mollit_anim_id_est_laborum."; - - $tests += [ - "Long path" => [ - "/wiki/$1", - "/wiki/$lorem", - [ 'title' => $lorem ] - ], - - // Ensure that the php passed site of parameter values are not urldecoded - "Pattern urlencoding" => [ - [ [ 'path' => "/wiki/$1", 'params' => [ 'title' => '%20:$1' ] ] ], - "/wiki/Foo", - [ 'title' => '%20:Foo' ] - ], - - // Ensure that raw parameter values do not have any variable replacements or urldecoding - "Raw param value" => [ - [ [ 'path' => "/wiki/$1", 'params' => [ 'title' => [ 'value' => 'bar%20$1' ] ] ] ], - "/wiki/Foo", - [ 'title' => 'bar%20$1' ] - ] - ]; - - return $tests; - } - - /** - * Test path parsing - * @dataProvider provideParse - */ - public function testParse( $patterns, $path, $expected ) { - $patterns = (array)$patterns; - - $router = new PathRouter; - foreach ( $patterns as $pattern ) { - if ( is_array( $pattern ) ) { - $router->add( $pattern['path'], $pattern['params'] ?? [], - $pattern['options'] ?? [] ); - } else { - $router->add( $pattern ); - } - } - $matches = $router->parse( $path ); - $this->assertEquals( $matches, $expected ); - } - - public static function callbackForTest( &$matches, $data ) { - $matches['x'] = $data['$1']; - $matches['foo'] = $data['foo']; - } - - public static function provideWeight() { - return [ - [ '/Foo', [ 'title' => 'Foo' ] ], - [ '/Bar', [ 'ping' => 'pong' ] ], - [ '/Baz', [ 'marco' => 'polo' ] ], - [ '/asdf-foo', [ 'title' => 'qwerty-foo' ] ], - [ '/qwerty-bar', [ 'title' => 'asdf-bar' ] ], - [ '/a/Foo', [ 'title' => 'Foo' ] ], - [ '/asdf/Foo', [ 'title' => 'Foo' ] ], - [ '/qwerty/Foo', [ 'title' => 'Foo', 'qwerty' => 'qwerty' ] ], - [ '/baz/Foo', [ 'title' => 'Foo', 'unrestricted' => 'baz' ] ], - [ '/y/Foo', [ 'title' => 'Foo', 'restricted-to-y' => 'y' ] ], - ]; - } - - /** - * Test to ensure weight of paths is handled correctly - * @dataProvider provideWeight - */ - public function testWeight( $path, $expected ) { - $router = new PathRouter; - $router->addStrict( "/Bar", [ 'ping' => 'pong' ] ); - $router->add( "/asdf-$1", [ 'title' => 'qwerty-$1' ] ); - $router->add( "/$1" ); - $router->add( "/qwerty-$1", [ 'title' => 'asdf-$1' ] ); - $router->addStrict( "/Baz", [ 'marco' => 'polo' ] ); - $router->add( "/a/$1" ); - $router->add( "/asdf/$1" ); - $router->add( "/$2/$1", [ 'unrestricted' => '$2' ] ); - $router->add( [ 'qwerty' => "/qwerty/$1" ], [ 'qwerty' => '$key' ] ); - $router->add( "/$2/$1", [ 'restricted-to-y' => '$2' ], [ '$2' => 'y' ] ); - - $this->assertEquals( $router->parse( $path ), $expected ); - } -} diff --git a/tests/phpunit/includes/Permissions/PermissionManagerTest.php b/tests/phpunit/includes/Permissions/PermissionManagerTest.php index 2ce50b7e9d..8a98217326 100644 --- a/tests/phpunit/includes/Permissions/PermissionManagerTest.php +++ b/tests/phpunit/includes/Permissions/PermissionManagerTest.php @@ -3,16 +3,26 @@ namespace MediaWiki\Tests\Permissions; use Action; -use MediaWikiLangTestCase; -use RequestContext; -use Title; -use User; +use ContentHandler; +use FauxRequest; use MediaWiki\Block\DatabaseBlock; use MediaWiki\Block\Restriction\NamespaceRestriction; use MediaWiki\Block\Restriction\PageRestriction; use MediaWiki\Block\SystemBlock; +use MediaWiki\Linker\LinkTarget; use MediaWiki\MediaWikiServices; use MediaWiki\Permissions\PermissionManager; +use MediaWiki\Revision\MutableRevisionRecord; +use MediaWiki\Revision\RevisionLookup; +use Wikimedia\ScopedCallback; +use MediaWiki\Session\SessionId; +use MediaWiki\Session\TestUtils; +use MediaWikiLangTestCase; +use RequestContext; +use stdClass; +use Title; +use User; +use Wikimedia\TestingAccessWrapper; /** * @group Database @@ -36,11 +46,6 @@ class PermissionManagerTest extends MediaWikiLangTestCase { */ protected $user, $anonUser, $userUser, $altUser; - /** - * @var PermissionManager - */ - protected $permissionManager; - /** Constant for self::testIsBlockedFrom */ const USER_TALK_PAGE = ''; @@ -56,7 +61,32 @@ class PermissionManagerTest extends MediaWikiLangTestCase { 'wgNamespaceProtection' => [ NS_MEDIAWIKI => 'editinterface', ], + 'wgRevokePermissions' => [ + 'formertesters' => [ + 'runtest' => true + ] + ], + 'wgAvailableRights' => [ + 'test', + 'runtest', + 'writetest', + 'nukeworld', + 'modifytest', + 'editmyoptions' + ] ] ); + + $this->setGroupPermissions( 'unittesters', 'test', true ); + $this->setGroupPermissions( 'unittesters', 'runtest', true ); + $this->setGroupPermissions( 'unittesters', 'writetest', false ); + $this->setGroupPermissions( 'unittesters', 'nukeworld', false ); + + $this->setGroupPermissions( 'testwriters', 'test', true ); + $this->setGroupPermissions( 'testwriters', 'writetest', true ); + $this->setGroupPermissions( 'testwriters', 'modifytest', true ); + + $this->setGroupPermissions( '*', 'editmyoptions', true ); + // Without this testUserBlock will use a non-English context on non-English MediaWiki // installations (because of how Title::checkUserBlock is implemented) and fail. RequestContext::resetMain(); @@ -89,19 +119,12 @@ class PermissionManagerTest extends MediaWikiLangTestCase { $this->user = $this->userUser; } - $this->permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); - - $this->overrideMwServices(); + $this->resetServices(); } - protected function setUserPerm( $perm ) { - // Setting member variables is evil!!! - - if ( is_array( $perm ) ) { - $this->user->mRights = $perm; - } else { - $this->user->mRights = [ $perm ]; - } + public function tearDown() { + parent::tearDown(); + $this->restoreMwServices(); } protected function setTitle( $ns, $title = "Main_Page" ) { @@ -116,6 +139,7 @@ class PermissionManagerTest extends MediaWikiLangTestCase { } else { $this->user = $this->altUser; } + $this->resetServices(); } /** @@ -133,163 +157,165 @@ class PermissionManagerTest extends MediaWikiLangTestCase { $this->setUser( 'anon' ); $this->setTitle( NS_TALK ); - $this->setUserPerm( "createtalk" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "createtalk" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'create', $this->user, $this->title ); $this->assertEquals( [], $res ); $this->setTitle( NS_TALK ); - $this->setUserPerm( "createpage" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "createpage" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'create', $this->user, $this->title ); $this->assertEquals( [ [ "nocreatetext" ] ], $res ); $this->setTitle( NS_TALK ); - $this->setUserPerm( "" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'create', $this->user, $this->title ); $this->assertEquals( [ [ 'nocreatetext' ] ], $res ); $this->setTitle( NS_MAIN ); - $this->setUserPerm( "createpage" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "createpage" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'create', $this->user, $this->title ); $this->assertEquals( [], $res ); $this->setTitle( NS_MAIN ); - $this->setUserPerm( "createtalk" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "createtalk" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'create', $this->user, $this->title ); $this->assertEquals( [ [ 'nocreatetext' ] ], $res ); $this->setUser( $this->userName ); $this->setTitle( NS_TALK ); - $this->setUserPerm( "createtalk" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "createtalk" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'create', $this->user, $this->title ); $this->assertEquals( [], $res ); $this->setTitle( NS_TALK ); - $this->setUserPerm( "createpage" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "createpage" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'create', $this->user, $this->title ); $this->assertEquals( [ [ 'nocreate-loggedin' ] ], $res ); $this->setTitle( NS_TALK ); - $this->setUserPerm( "" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'create', $this->user, $this->title ); $this->assertEquals( [ [ 'nocreate-loggedin' ] ], $res ); $this->setTitle( NS_MAIN ); - $this->setUserPerm( "createpage" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "createpage" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'create', $this->user, $this->title ); $this->assertEquals( [], $res ); $this->setTitle( NS_MAIN ); - $this->setUserPerm( "createtalk" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "createtalk" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'create', $this->user, $this->title ); $this->assertEquals( [ [ 'nocreate-loggedin' ] ], $res ); $this->setTitle( NS_MAIN ); - $this->setUserPerm( "" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'create', $this->user, $this->title ); $this->assertEquals( [ [ 'nocreate-loggedin' ] ], $res ); $this->setUser( 'anon' ); $this->setTitle( NS_USER, $this->userName . '' ); - $this->setUserPerm( "" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move', $this->user, $this->title ); $this->assertEquals( [ [ 'cant-move-user-page' ], [ 'movenologintext' ] ], $res ); $this->setTitle( NS_USER, $this->userName . '/subpage' ); - $this->setUserPerm( "" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move', $this->user, $this->title ); $this->assertEquals( [ [ 'movenologintext' ] ], $res ); $this->setTitle( NS_USER, $this->userName . '' ); - $this->setUserPerm( "move-rootuserpages" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "move-rootuserpages" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move', $this->user, $this->title ); $this->assertEquals( [ [ 'movenologintext' ] ], $res ); $this->setTitle( NS_USER, $this->userName . '/subpage' ); - $this->setUserPerm( "move-rootuserpages" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "move-rootuserpages" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move', $this->user, $this->title ); $this->assertEquals( [ [ 'movenologintext' ] ], $res ); $this->setTitle( NS_USER, $this->userName . '' ); - $this->setUserPerm( "" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move', $this->user, $this->title ); $this->assertEquals( [ [ 'cant-move-user-page' ], [ 'movenologintext' ] ], $res ); $this->setTitle( NS_USER, $this->userName . '/subpage' ); - $this->setUserPerm( "" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move', $this->user, $this->title ); $this->assertEquals( [ [ 'movenologintext' ] ], $res ); $this->setTitle( NS_USER, $this->userName . '' ); - $this->setUserPerm( "move-rootuserpages" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "move-rootuserpages" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move', $this->user, $this->title ); $this->assertEquals( [ [ 'movenologintext' ] ], $res ); $this->setTitle( NS_USER, $this->userName . '/subpage' ); - $this->setUserPerm( "move-rootuserpages" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "move-rootuserpages" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move', $this->user, $this->title ); $this->assertEquals( [ [ 'movenologintext' ] ], $res ); $this->setUser( $this->userName ); $this->setTitle( NS_FILE, "img.png" ); - $this->setUserPerm( "" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move', $this->user, $this->title ); $this->assertEquals( [ [ 'movenotallowedfile' ], [ 'movenotallowed' ] ], $res ); $this->setTitle( NS_FILE, "img.png" ); - $this->setUserPerm( "movefile" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "movefile" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move', $this->user, $this->title ); $this->assertEquals( [ [ 'movenotallowed' ] ], $res ); $this->setUser( 'anon' ); $this->setTitle( NS_FILE, "img.png" ); - $this->setUserPerm( "" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move', $this->user, $this->title ); $this->assertEquals( [ [ 'movenotallowedfile' ], [ 'movenologintext' ] ], $res ); $this->setTitle( NS_FILE, "img.png" ); - $this->setUserPerm( "movefile" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "movefile" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move', $this->user, $this->title ); $this->assertEquals( [ [ 'movenologintext' ] ], $res ); $this->setUser( $this->userName ); - $this->setUserPerm( "move" ); - $this->runGroupPermissions( 'move', [ [ 'movenotallowedfile' ] ] ); + // $this->setUserPerm( "move" ); + $this->runGroupPermissions( 'move', 'move', [ [ 'movenotallowedfile' ] ] ); - $this->setUserPerm( "" ); + // $this->setUserPerm( "" ); $this->runGroupPermissions( + '', 'move', [ [ 'movenotallowedfile' ], [ 'movenotallowed' ] ] ); $this->setUser( 'anon' ); - $this->setUserPerm( "move" ); - $this->runGroupPermissions( 'move', [ [ 'movenotallowedfile' ] ] ); + //$this->setUserPerm( "move" ); + $this->runGroupPermissions( 'move', 'move', [ [ 'movenotallowedfile' ] ] ); - $this->setUserPerm( "" ); + // $this->setUserPerm( "" ); $this->runGroupPermissions( + '', 'move', [ [ 'movenotallowedfile' ], [ 'movenotallowed' ] ], [ [ 'movenotallowedfile' ], [ 'movenologintext' ] ] @@ -301,58 +327,58 @@ class PermissionManagerTest extends MediaWikiLangTestCase { $this->setTitle( NS_MAIN ); $this->setUser( 'anon' ); - $this->setUserPerm( "move" ); - $this->runGroupPermissions( 'move', [] ); + // $this->setUserPerm( "move" ); + $this->runGroupPermissions( 'move', 'move', [] ); - $this->setUserPerm( "" ); - $this->runGroupPermissions( 'move', [ [ 'movenotallowed' ] ], + // $this->setUserPerm( "" ); + $this->runGroupPermissions( '', 'move', [ [ 'movenotallowed' ] ], [ [ 'movenologintext' ] ] ); $this->setUser( $this->userName ); - $this->setUserPerm( "" ); - $this->runGroupPermissions( 'move', [ [ 'movenotallowed' ] ] ); + // $this->setUserPerm( "" ); + $this->runGroupPermissions( '', 'move', [ [ 'movenotallowed' ] ] ); - $this->setUserPerm( "move" ); - $this->runGroupPermissions( 'move', [] ); + //$this->setUserPerm( "move" ); + $this->runGroupPermissions( 'move', 'move', [] ); $this->setUser( 'anon' ); - $this->setUserPerm( 'move' ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, 'move' ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move-target', $this->user, $this->title ); $this->assertEquals( [], $res ); - $this->setUserPerm( '' ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, '' ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move-target', $this->user, $this->title ); $this->assertEquals( [ [ 'movenotallowed' ] ], $res ); } $this->setTitle( NS_USER ); $this->setUser( $this->userName ); - $this->setUserPerm( [ "move", "move-rootuserpages" ] ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, [ "move", "move-rootuserpages" ] ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move-target', $this->user, $this->title ); $this->assertEquals( [], $res ); - $this->setUserPerm( "move" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "move" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move-target', $this->user, $this->title ); $this->assertEquals( [ [ 'cant-move-to-user-page' ] ], $res ); $this->setUser( 'anon' ); - $this->setUserPerm( [ "move", "move-rootuserpages" ] ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, [ "move", "move-rootuserpages" ] ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move-target', $this->user, $this->title ); $this->assertEquals( [], $res ); $this->setTitle( NS_USER, "User/subpage" ); - $this->setUserPerm( [ "move", "move-rootuserpages" ] ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, [ "move", "move-rootuserpages" ] ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move-target', $this->user, $this->title ); $this->assertEquals( [], $res ); - $this->setUserPerm( "move" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "move" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move-target', $this->user, $this->title ); $this->assertEquals( [], $res ); @@ -378,54 +404,58 @@ class PermissionManagerTest extends MediaWikiLangTestCase { ]; foreach ( [ "edit", "protect", "" ] as $action ) { - $this->setUserPerm( null ); + $this->overrideUserPermissions( $this->user ); $this->assertEquals( $check[$action][0], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( $action, $this->user, $this->title, true ) ); $this->assertEquals( $check[$action][0], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( $action, $this->user, $this->title, 'full' ) ); $this->assertEquals( $check[$action][0], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( $action, $this->user, $this->title, 'secure' ) ); global $wgGroupPermissions; $old = $wgGroupPermissions; $wgGroupPermissions = []; + $this->resetServices(); $this->assertEquals( $check[$action][1], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( $action, $this->user, $this->title, true ) ); $this->assertEquals( $check[$action][1], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( $action, $this->user, $this->title, 'full' ) ); $this->assertEquals( $check[$action][1], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( $action, $this->user, $this->title, 'secure' ) ); $wgGroupPermissions = $old; + $this->resetServices(); - $this->setUserPerm( $action ); + $this->overrideUserPermissions( $this->user, $action ); $this->assertEquals( $check[$action][2], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( $action, $this->user, $this->title, true ) ); $this->assertEquals( $check[$action][2], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( $action, $this->user, $this->title, 'full' ) ); $this->assertEquals( $check[$action][2], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( $action, $this->user, $this->title, 'secure' ) ); - $this->setUserPerm( $action ); + $this->overrideUserPermissions( $this->user, $action ); $this->assertEquals( $check[$action][3], - $this->permissionManager->userCan( $action, $this->user, $this->title, true ) ); + MediaWikiServices::getInstance()->getPermissionManager() + ->userCan( $action, $this->user, $this->title, true ) ); $this->assertEquals( $check[$action][3], - $this->permissionManager->userCan( $action, $this->user, $this->title, + MediaWikiServices::getInstance()->getPermissionManager() + ->userCan( $action, $this->user, $this->title, PermissionManager::RIGOR_QUICK ) ); # count( User::getGroupsWithPermissions( $action ) ) < 1 } } - protected function runGroupPermissions( $action, $result, $result2 = null ) { + protected function runGroupPermissions( $perm, $action, $result, $result2 = null ) { global $wgGroupPermissions; if ( $result2 === null ) { @@ -434,25 +464,33 @@ class PermissionManagerTest extends MediaWikiLangTestCase { $wgGroupPermissions['autoconfirmed']['move'] = false; $wgGroupPermissions['user']['move'] = false; - $res = $this->permissionManager + $this->resetServices(); + $this->overrideUserPermissions( $this->user, $perm ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( $action, $this->user, $this->title ); $this->assertEquals( $result, $res ); $wgGroupPermissions['autoconfirmed']['move'] = true; $wgGroupPermissions['user']['move'] = false; - $res = $this->permissionManager + $this->resetServices(); + $this->overrideUserPermissions( $this->user, $perm ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( $action, $this->user, $this->title ); $this->assertEquals( $result2, $res ); $wgGroupPermissions['autoconfirmed']['move'] = true; $wgGroupPermissions['user']['move'] = true; - $res = $this->permissionManager + $this->resetServices(); + $this->overrideUserPermissions( $this->user, $perm ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( $action, $this->user, $this->title ); $this->assertEquals( $result2, $res ); $wgGroupPermissions['autoconfirmed']['move'] = false; $wgGroupPermissions['user']['move'] = true; - $res = $this->permissionManager + $this->resetServices(); + $this->overrideUserPermissions( $this->user, $perm ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( $action, $this->user, $this->title ); $this->assertEquals( $result2, $res ); } @@ -469,57 +507,59 @@ class PermissionManagerTest extends MediaWikiLangTestCase { $this->setTitle( NS_SPECIAL ); $this->assertEquals( [ [ 'badaccess-group0' ], [ 'ns-specialprotected' ] ], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'bogus', $this->user, $this->title ) ); $this->setTitle( NS_MAIN ); - $this->setUserPerm( 'bogus' ); + $this->overrideUserPermissions( $this->user, 'bogus' ); $this->assertEquals( [], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'bogus', $this->user, $this->title ) ); $this->setTitle( NS_MAIN ); - $this->setUserPerm( '' ); + $this->overrideUserPermissions( $this->user, '' ); $this->assertEquals( [ [ 'badaccess-group0' ] ], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'bogus', $this->user, $this->title ) ); $wgNamespaceProtection[NS_USER] = [ 'bogus' ]; $this->setTitle( NS_USER ); - $this->setUserPerm( '' ); + $this->overrideUserPermissions( $this->user, '' ); $this->assertEquals( [ [ 'badaccess-group0' ], [ 'namespaceprotected', 'User', 'bogus' ] ], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'bogus', $this->user, $this->title ) ); $this->setTitle( NS_MEDIAWIKI ); - $this->setUserPerm( 'bogus' ); + $this->overrideUserPermissions( $this->user, 'bogus' ); $this->assertEquals( [ [ 'protectedinterface', 'bogus' ] ], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'bogus', $this->user, $this->title ) ); $this->setTitle( NS_MEDIAWIKI ); - $this->setUserPerm( 'bogus' ); + $this->overrideUserPermissions( $this->user, 'bogus' ); $this->assertEquals( [ [ 'protectedinterface', 'bogus' ] ], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'bogus', $this->user, $this->title ) ); $wgNamespaceProtection = null; - $this->setUserPerm( 'bogus' ); + $this->overrideUserPermissions( $this->user, 'bogus' ); $this->assertEquals( [], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'bogus', $this->user, $this->title ) ); $this->assertEquals( true, - $this->permissionManager->userCan( 'bogus', $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager() + ->userCan( 'bogus', $this->user, $this->title ) ); - $this->setUserPerm( '' ); + $this->overrideUserPermissions( $this->user, '' ); $this->assertEquals( [ [ 'badaccess-group0' ] ], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'bogus', $this->user, $this->title ) ); $this->assertEquals( false, - $this->permissionManager->userCan( 'bogus', $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager() + ->userCan( 'bogus', $this->user, $this->title ) ); } /** @@ -662,6 +702,64 @@ class PermissionManagerTest extends MediaWikiLangTestCase { ); } + public function testJsConfigRedirectEditPermissions() { + $revision = null; + $user = $this->getTestUser()->getUser(); + $otherUser = $this->getTestUser( 'sysop' )->getUser(); + $localJsTitle = Title::newFromText( 'User:' . $user->getName() . '/foo.js' ); + $otherLocalJsTitle = Title::newFromText( 'User:' . $user->getName() . '/foo2.js' ); + $nonlocalJsTitle = Title::newFromText( 'User:' . $otherUser->getName() . '/foo.js' ); + + $services = MediaWikiServices::getInstance(); + $revisionLookup = $this->getMockBuilder( RevisionLookup::class ) + ->setMethods( [ 'getRevisionByTitle' ] ) + ->getMockForAbstractClass(); + $revisionLookup->method( 'getRevisionByTitle' ) + ->willReturnCallback( function ( LinkTarget $page ) use ( + $services, &$revision, $localJsTitle + ) { + if ( $localJsTitle->equals( Title::newFromLinkTarget( $page ) ) ) { + return $revision; + } else { + return $services->getRevisionLookup()->getRevisionByTitle( $page ); + } + } ); + $permissionManager = new PermissionManager( + $services->getSpecialPageFactory(), + $revisionLookup, + [], + [], + false, + false, + [], + [], + [], + MediaWikiServices::getInstance()->getNamespaceInfo() + ); + $this->setService( 'PermissionManager', $permissionManager ); + + $permissionManager->overrideUserRightsForTesting( $user, [ 'edit', 'editmyuserjs' ] ); + + $revision = $this->getJavascriptRevision( $localJsTitle, $user, '/* script */' ); + $errors = $permissionManager->getPermissionErrors( 'edit', $user, $localJsTitle ); + $this->assertSame( [], $errors ); + + $revision = $this->getJavascriptRedirectRevision( $localJsTitle, $otherLocalJsTitle, $user ); + $errors = $permissionManager->getPermissionErrors( 'edit', $user, $localJsTitle ); + $this->assertSame( [], $errors ); + + $revision = $this->getJavascriptRedirectRevision( $localJsTitle, $nonlocalJsTitle, $user ); + $errors = $permissionManager->getPermissionErrors( 'edit', $user, $localJsTitle ); + $this->assertSame( [ [ 'mycustomjsredirectprotected', 'edit' ] ], $errors ); + + $permissionManager->overrideUserRightsForTesting( $user, + [ 'edit', 'editmyuserjs', 'editmyuserjsredirect' ] ); + + $revision = $this->getJavascriptRedirectRevision( $localJsTitle, $nonlocalJsTitle, $user ); + $errors = $permissionManager->getPermissionErrors( 'edit', $user, $localJsTitle ); + $this->assertSame( [], $errors ); + } + /** * @todo This test method should be split up into separate test methods and * data providers @@ -716,48 +814,48 @@ class PermissionManagerTest extends MediaWikiLangTestCase { $resultUserJs, $resultPatrol ) { - $this->setUserPerm( '' ); - $result = $this->permissionManager + $this->overrideUserPermissions( $this->user ); + $result = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'bogus', $this->user, $this->title ); $this->assertEquals( $resultNone, $result ); - $this->setUserPerm( 'editmyusercss' ); - $result = $this->permissionManager + $this->overrideUserPermissions( $this->user, 'editmyusercss' ); + $result = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'bogus', $this->user, $this->title ); $this->assertEquals( $resultMyCss, $result ); - $this->setUserPerm( 'editmyuserjson' ); - $result = $this->permissionManager + $this->overrideUserPermissions( $this->user, 'editmyuserjson' ); + $result = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'bogus', $this->user, $this->title ); $this->assertEquals( $resultMyJson, $result ); - $this->setUserPerm( 'editmyuserjs' ); - $result = $this->permissionManager + $this->overrideUserPermissions( $this->user, 'editmyuserjs' ); + $result = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'bogus', $this->user, $this->title ); $this->assertEquals( $resultMyJs, $result ); - $this->setUserPerm( 'editusercss' ); - $result = $this->permissionManager + $this->overrideUserPermissions( $this->user, 'editusercss' ); + $result = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'bogus', $this->user, $this->title ); $this->assertEquals( $resultUserCss, $result ); - $this->setUserPerm( 'edituserjson' ); - $result = $this->permissionManager + $this->overrideUserPermissions( $this->user, 'edituserjson' ); + $result = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'bogus', $this->user, $this->title ); $this->assertEquals( $resultUserJson, $result ); - $this->setUserPerm( 'edituserjs' ); - $result = $this->permissionManager + $this->overrideUserPermissions( $this->user, 'edituserjs' ); + $result = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'bogus', $this->user, $this->title ); $this->assertEquals( $resultUserJs, $result ); - $this->setUserPerm( '' ); - $result = $this->permissionManager + $this->overrideUserPermissions( $this->user ); + $result = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'patrol', $this->user, $this->title ); $this->assertEquals( reset( $resultPatrol[0] ), reset( $result[0] ) ); - $this->setUserPerm( [ 'edituserjs', 'edituserjson', 'editusercss' ] ); - $result = $this->permissionManager + $this->overrideUserPermissions( $this->user, [ 'edituserjs', 'edituserjson', 'editusercss' ] ); + $result = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'bogus', $this->user, $this->title ); $this->assertEquals( [ [ 'badaccess-group0' ] ], $result ); } @@ -777,16 +875,16 @@ class PermissionManagerTest extends MediaWikiLangTestCase { $this->setTitle( NS_MAIN ); $this->title->mRestrictionsLoaded = true; - $this->setUserPerm( "edit" ); + $this->overrideUserPermissions( $this->user, "edit" ); $this->title->mRestrictions = [ "bogus" => [ 'bogus', "sysop", "protect", "" ] ]; $this->assertEquals( [], - $this->permissionManager->getPermissionErrors( 'edit', - $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager() + ->getPermissionErrors( 'edit', $this->user, $this->title ) ); $this->assertEquals( true, - $this->permissionManager->userCan( 'edit', $this->user, $this->title, - PermissionManager::RIGOR_QUICK ) ); + MediaWikiServices::getInstance()->getPermissionManager() + ->userCan( 'edit', $this->user, $this->title, PermissionManager::RIGOR_QUICK ) ); $this->title->mRestrictions = [ "edit" => [ 'bogus', "sysop", "protect", "" ], "bogus" => [ 'bogus', "sysop", "protect", "" ] ]; @@ -795,81 +893,81 @@ class PermissionManagerTest extends MediaWikiLangTestCase { [ 'protectedpagetext', 'bogus', 'bogus' ], [ 'protectedpagetext', 'editprotected', 'bogus' ], [ 'protectedpagetext', 'protect', 'bogus' ] ], - $this->permissionManager->getPermissionErrors( 'bogus', - $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->getPermissionErrors( + 'bogus', $this->user, $this->title ) ); $this->assertEquals( [ [ 'protectedpagetext', 'bogus', 'edit' ], [ 'protectedpagetext', 'editprotected', 'edit' ], [ 'protectedpagetext', 'protect', 'edit' ] ], - $this->permissionManager->getPermissionErrors( 'edit', - $this->user, $this->title ) ); - $this->setUserPerm( "" ); + MediaWikiServices::getInstance()->getPermissionManager()->getPermissionErrors( + 'edit', $this->user, $this->title ) ); + $this->overrideUserPermissions( $this->user ); $this->assertEquals( [ [ 'badaccess-group0' ], [ 'protectedpagetext', 'bogus', 'bogus' ], [ 'protectedpagetext', 'editprotected', 'bogus' ], [ 'protectedpagetext', 'protect', 'bogus' ] ], - $this->permissionManager->getPermissionErrors( 'bogus', - $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->getPermissionErrors( + 'bogus', $this->user, $this->title ) ); $this->assertEquals( [ [ 'badaccess-groups', "*, [[$prefix:Users|Users]]", 2 ], [ 'protectedpagetext', 'bogus', 'edit' ], [ 'protectedpagetext', 'editprotected', 'edit' ], [ 'protectedpagetext', 'protect', 'edit' ] ], - $this->permissionManager->getPermissionErrors( 'edit', - $this->user, $this->title ) ); - $this->setUserPerm( [ "edit", "editprotected" ] ); + MediaWikiServices::getInstance()->getPermissionManager()->getPermissionErrors( + 'edit', $this->user, $this->title ) ); + $this->overrideUserPermissions( $this->user, [ "edit", "editprotected" ] ); $this->assertEquals( [ [ 'badaccess-group0' ], [ 'protectedpagetext', 'bogus', 'bogus' ], [ 'protectedpagetext', 'protect', 'bogus' ] ], - $this->permissionManager->getPermissionErrors( 'bogus', - $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->getPermissionErrors( + 'bogus', $this->user, $this->title ) ); $this->assertEquals( [ [ 'protectedpagetext', 'bogus', 'edit' ], [ 'protectedpagetext', 'protect', 'edit' ] ], - $this->permissionManager->getPermissionErrors( 'edit', - $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->getPermissionErrors( + 'edit', $this->user, $this->title ) ); $this->title->mCascadeRestriction = true; - $this->setUserPerm( "edit" ); + $this->overrideUserPermissions( $this->user, "edit" ); $this->assertEquals( false, - $this->permissionManager->userCan( 'bogus', $this->user, $this->title, - PermissionManager::RIGOR_QUICK ) ); + MediaWikiServices::getInstance()->getPermissionManager() + ->userCan( 'bogus', $this->user, $this->title, PermissionManager::RIGOR_QUICK ) ); $this->assertEquals( false, - $this->permissionManager->userCan( 'edit', $this->user, $this->title, - PermissionManager::RIGOR_QUICK ) ); + MediaWikiServices::getInstance()->getPermissionManager()->userCan( + 'edit', $this->user, $this->title, PermissionManager::RIGOR_QUICK ) ); $this->assertEquals( [ [ 'badaccess-group0' ], [ 'protectedpagetext', 'bogus', 'bogus' ], [ 'protectedpagetext', 'editprotected', 'bogus' ], [ 'protectedpagetext', 'protect', 'bogus' ] ], - $this->permissionManager->getPermissionErrors( 'bogus', - $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->getPermissionErrors( + 'bogus', $this->user, $this->title ) ); $this->assertEquals( [ [ 'protectedpagetext', 'bogus', 'edit' ], [ 'protectedpagetext', 'editprotected', 'edit' ], [ 'protectedpagetext', 'protect', 'edit' ] ], - $this->permissionManager->getPermissionErrors( 'edit', - $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->getPermissionErrors( + 'edit', $this->user, $this->title ) ); - $this->setUserPerm( [ "edit", "editprotected" ] ); + $this->overrideUserPermissions( $this->user, [ "edit", "editprotected" ] ); $this->assertEquals( false, - $this->permissionManager->userCan( 'bogus', $this->user, $this->title, - PermissionManager::RIGOR_QUICK ) ); + MediaWikiServices::getInstance()->getPermissionManager()->userCan( + 'bogus', $this->user, $this->title, PermissionManager::RIGOR_QUICK ) ); $this->assertEquals( false, - $this->permissionManager->userCan( 'edit', $this->user, $this->title, - PermissionManager::RIGOR_QUICK ) ); + MediaWikiServices::getInstance()->getPermissionManager()->userCan( + 'edit', $this->user, $this->title, PermissionManager::RIGOR_QUICK ) ); $this->assertEquals( [ [ 'badaccess-group0' ], [ 'protectedpagetext', 'bogus', 'bogus' ], [ 'protectedpagetext', 'protect', 'bogus' ], [ 'protectedpagetext', 'protect', 'bogus' ] ], - $this->permissionManager->getPermissionErrors( 'bogus', - $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->getPermissionErrors( + 'bogus', $this->user, $this->title ) ); $this->assertEquals( [ [ 'protectedpagetext', 'bogus', 'edit' ], [ 'protectedpagetext', 'protect', 'edit' ], [ 'protectedpagetext', 'protect', 'edit' ] ], - $this->permissionManager->getPermissionErrors( 'edit', - $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->getPermissionErrors( + 'edit', $this->user, $this->title ) ); } /** @@ -877,7 +975,7 @@ class PermissionManagerTest extends MediaWikiLangTestCase { */ public function testCascadingSourcesRestrictions() { $this->setTitle( NS_MAIN, "test page" ); - $this->setUserPerm( [ "edit", "bogus" ] ); + $this->overrideUserPermissions( $this->user, [ "edit", "bogus" ] ); $this->title->mCascadeSources = [ Title::makeTitle( NS_MAIN, "Bogus" ), @@ -888,17 +986,21 @@ class PermissionManagerTest extends MediaWikiLangTestCase { ]; $this->assertEquals( false, - $this->permissionManager->userCan( 'bogus', $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->userCan( + 'bogus', $this->user, $this->title ) ); $this->assertEquals( [ [ "cascadeprotected", 2, "* [[:Bogus]]\n* [[:UnBogus]]\n", 'bogus' ], [ "cascadeprotected", 2, "* [[:Bogus]]\n* [[:UnBogus]]\n", 'bogus' ], [ "cascadeprotected", 2, "* [[:Bogus]]\n* [[:UnBogus]]\n", 'bogus' ] ], - $this->permissionManager->getPermissionErrors( 'bogus', $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->getPermissionErrors( + 'bogus', $this->user, $this->title ) ); $this->assertEquals( true, - $this->permissionManager->userCan( 'edit', $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->userCan( + 'edit', $this->user, $this->title ) ); $this->assertEquals( [], - $this->permissionManager->getPermissionErrors( 'edit', $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->getPermissionErrors( + 'edit', $this->user, $this->title ) ); } /** @@ -907,7 +1009,7 @@ class PermissionManagerTest extends MediaWikiLangTestCase { * @covers \MediaWiki\Permissions\PermissionManager::checkActionPermissions */ public function testActionPermissions() { - $this->setUserPerm( [ "createpage" ] ); + $this->overrideUserPermissions( $this->user, [ "createpage" ] ); $this->setTitle( NS_MAIN, "test page" ); $this->title->mTitleProtection['permission'] = ''; $this->title->mTitleProtection['user'] = $this->user->getId(); @@ -916,75 +1018,85 @@ class PermissionManagerTest extends MediaWikiLangTestCase { $this->title->mCascadeRestriction = false; $this->assertEquals( [ [ 'titleprotected', 'Useruser', 'test' ] ], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'create', $this->user, $this->title ) ); $this->assertEquals( false, - $this->permissionManager->userCan( 'create', $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->userCan( + 'create', $this->user, $this->title ) ); $this->title->mTitleProtection['permission'] = 'editprotected'; - $this->setUserPerm( [ 'createpage', 'protect' ] ); + $this->overrideUserPermissions( $this->user, [ 'createpage', 'protect' ] ); $this->assertEquals( [ [ 'titleprotected', 'Useruser', 'test' ] ], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'create', $this->user, $this->title ) ); $this->assertEquals( false, - $this->permissionManager->userCan( 'create', $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->userCan( + 'create', $this->user, $this->title ) ); - $this->setUserPerm( [ 'createpage', 'editprotected' ] ); + $this->overrideUserPermissions( $this->user, [ 'createpage', 'editprotected' ] ); $this->assertEquals( [], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'create', $this->user, $this->title ) ); $this->assertEquals( true, - $this->permissionManager->userCan( 'create', $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->userCan( + 'create', $this->user, $this->title ) ); - $this->setUserPerm( [ 'createpage' ] ); + $this->overrideUserPermissions( $this->user, [ 'createpage' ] ); $this->assertEquals( [ [ 'titleprotected', 'Useruser', 'test' ] ], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'create', $this->user, $this->title ) ); $this->assertEquals( false, - $this->permissionManager->userCan( 'create', $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->userCan( + 'create', $this->user, $this->title ) ); $this->setTitle( NS_MEDIA, "test page" ); - $this->setUserPerm( [ "move" ] ); + $this->overrideUserPermissions( $this->user, [ "move" ] ); $this->assertEquals( false, - $this->permissionManager->userCan( 'move', $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->userCan( + 'move', $this->user, $this->title ) ); $this->assertEquals( [ [ 'immobile-source-namespace', 'Media' ] ], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move', $this->user, $this->title ) ); $this->setTitle( NS_HELP, "test page" ); $this->assertEquals( [], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move', $this->user, $this->title ) ); $this->assertEquals( true, - $this->permissionManager->userCan( 'move', $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->userCan( + 'move', $this->user, $this->title ) ); $this->title->mInterwiki = "no"; $this->assertEquals( [ [ 'immobile-source-page' ] ], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move', $this->user, $this->title ) ); $this->assertEquals( false, - $this->permissionManager->userCan( 'move', $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->userCan( + 'move', $this->user, $this->title ) ); $this->setTitle( NS_MEDIA, "test page" ); $this->assertEquals( false, - $this->permissionManager->userCan( 'move-target', $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->userCan( + 'move-target', $this->user, $this->title ) ); $this->assertEquals( [ [ 'immobile-target-namespace', 'Media' ] ], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move-target', $this->user, $this->title ) ); $this->setTitle( NS_HELP, "test page" ); $this->assertEquals( [], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move-target', $this->user, $this->title ) ); $this->assertEquals( true, - $this->permissionManager->userCan( 'move-target', $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->userCan( + 'move-target', $this->user, $this->title ) ); $this->title->mInterwiki = "no"; $this->assertEquals( [ [ 'immobile-target-page' ] ], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move-target', $this->user, $this->title ) ); $this->assertEquals( false, - $this->permissionManager->userCan( 'move-target', $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->userCan( + 'move-target', $this->user, $this->title ) ); } /** @@ -997,10 +1109,7 @@ class PermissionManagerTest extends MediaWikiLangTestCase { 'wgBlockDisablesLogin' => false, ] ); - $this->overrideMwServices(); - $this->permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); - - $this->setUserPerm( [ + $this->overrideUserPermissions( $this->user, [ 'createpage', 'edit', 'move', @@ -1013,24 +1122,32 @@ class PermissionManagerTest extends MediaWikiLangTestCase { # $wgEmailConfirmToEdit only applies to 'edit' action $this->assertEquals( [], - $this->permissionManager->getPermissionErrors( 'move-target', - $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->getPermissionErrors( + 'move-target', $this->user, $this->title ) ); $this->assertContains( [ 'confirmedittext' ], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'edit', $this->user, $this->title ) ); $this->setMwGlobals( 'wgEmailConfirmToEdit', false ); - $this->overrideMwServices(); - $this->permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); + $this->resetServices(); + $this->overrideUserPermissions( $this->user, [ + 'createpage', + 'edit', + 'move', + 'rollback', + 'patrol', + 'upload', + 'purge' + ] ); $this->assertNotContains( [ 'confirmedittext' ], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'edit', $this->user, $this->title ) ); # $wgEmailConfirmToEdit && !$user->isEmailConfirmed() && $action != 'createaccount' $this->assertEquals( [], - $this->permissionManager->getPermissionErrors( 'move-target', - $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->getPermissionErrors( + 'move-target', $this->user, $this->title ) ); global $wgLang; $prev = time(); @@ -1046,16 +1163,16 @@ class PermissionManagerTest extends MediaWikiLangTestCase { ] ); $this->user->mBlock->mTimestamp = 0; $this->assertEquals( [ [ 'autoblockedtext', - '[[User:Useruser|Useruser]]', 'no reason given', '127.0.0.1', - 'Useruser', null, 'infinite', '127.0.8.1', + "[[User:Useruser|\u{202A}Useruser\u{202C}]]", 'no reason given', '127.0.0.1', + "\u{202A}Useruser\u{202C}", null, 'infinite', '127.0.8.1', $wgLang->timeanddate( wfTimestamp( TS_MW, $prev ), true ) ] ], - $this->permissionManager->getPermissionErrors( 'move-target', - $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->getPermissionErrors( + 'move-target', $this->user, $this->title ) ); - $this->assertEquals( false, $this->permissionManager + $this->assertEquals( false, MediaWikiServices::getInstance()->getPermissionManager() ->userCan( 'move-target', $this->user, $this->title ) ); // quickUserCan should ignore user blocks - $this->assertEquals( true, $this->permissionManager + $this->assertEquals( true, MediaWikiServices::getInstance()->getPermissionManager() ->userCan( 'move-target', $this->user, $this->title, PermissionManager::RIGOR_QUICK ) ); @@ -1071,10 +1188,10 @@ class PermissionManagerTest extends MediaWikiLangTestCase { 'expiry' => 10, ] ); $this->assertEquals( [ [ 'blockedtext', - '[[User:Useruser|Useruser]]', 'no reason given', '127.0.0.1', - 'Useruser', null, '23:00, 31 December 1969', '127.0.8.1', + "[[User:Useruser|\u{202A}Useruser\u{202C}]]", 'no reason given', '127.0.0.1', + "\u{202A}Useruser\u{202C}", null, '23:00, 31 December 1969', '127.0.8.1', $wgLang->timeanddate( wfTimestamp( TS_MW, $now ), true ) ] ], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move-target', $this->user, $this->title ) ); # $action != 'read' && $action != 'createaccount' && $user->isBlockedFrom( $this ) # $user->blockedFor() == '' @@ -1091,27 +1208,27 @@ class PermissionManagerTest extends MediaWikiLangTestCase { ] ); $errors = [ [ 'systemblockedtext', - '[[User:Useruser|Useruser]]', 'no reason given', '127.0.0.1', - 'Useruser', 'test', 'infinite', '127.0.8.1', + "[[User:Useruser|\u{202A}Useruser\u{202C}]]", 'no reason given', '127.0.0.1', + "\u{202A}Useruser\u{202C}", 'test', 'infinite', '127.0.8.1', $wgLang->timeanddate( wfTimestamp( TS_MW, $now ), true ) ] ]; $this->assertEquals( $errors, - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'edit', $this->user, $this->title ) ); $this->assertEquals( $errors, - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move-target', $this->user, $this->title ) ); $this->assertEquals( $errors, - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'rollback', $this->user, $this->title ) ); $this->assertEquals( $errors, - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'patrol', $this->user, $this->title ) ); $this->assertEquals( $errors, - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'upload', $this->user, $this->title ) ); $this->assertEquals( [], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'purge', $this->user, $this->title ) ); // partial block message test @@ -1126,22 +1243,22 @@ class PermissionManagerTest extends MediaWikiLangTestCase { ] ); $this->assertEquals( [], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'edit', $this->user, $this->title ) ); $this->assertEquals( [], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move-target', $this->user, $this->title ) ); $this->assertEquals( [], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'rollback', $this->user, $this->title ) ); $this->assertEquals( [], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'patrol', $this->user, $this->title ) ); $this->assertEquals( [], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'upload', $this->user, $this->title ) ); $this->assertEquals( [], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'purge', $this->user, $this->title ) ); $this->user->mBlock->setRestrictions( [ @@ -1149,27 +1266,27 @@ class PermissionManagerTest extends MediaWikiLangTestCase { ] ); $errors = [ [ 'blockedtext-partial', - '[[User:Useruser|Useruser]]', 'no reason given', '127.0.0.1', - 'Useruser', null, '23:00, 31 December 1969', '127.0.8.1', + "[[User:Useruser|\u{202A}Useruser\u{202C}]]", 'no reason given', '127.0.0.1', + "\u{202A}Useruser\u{202C}", null, '23:00, 31 December 1969', '127.0.8.1', $wgLang->timeanddate( wfTimestamp( TS_MW, $now ), true ) ] ]; $this->assertEquals( $errors, - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'edit', $this->user, $this->title ) ); $this->assertEquals( $errors, - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move-target', $this->user, $this->title ) ); $this->assertEquals( $errors, - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'rollback', $this->user, $this->title ) ); $this->assertEquals( $errors, - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'patrol', $this->user, $this->title ) ); $this->assertEquals( [], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'upload', $this->user, $this->title ) ); $this->assertEquals( [], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'purge', $this->user, $this->title ) ); // Test no block. @@ -1177,7 +1294,7 @@ class PermissionManagerTest extends MediaWikiLangTestCase { $this->user->mBlock = null; $this->assertEquals( [], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'edit', $this->user, $this->title ) ); } @@ -1223,12 +1340,12 @@ class PermissionManagerTest extends MediaWikiLangTestCase { ] ); $errors = [ [ 'blockedtext', - '[[User:Useruser|Useruser]]', 'no reason given', '127.0.0.1', - 'Useruser', null, 'infinite', '127.0.8.1', + "[[User:Useruser|\u{202A}Useruser\u{202C}]]", 'no reason given', '127.0.0.1', + "\u{202A}Useruser\u{202C}", null, 'infinite', '127.0.8.1', $wgLang->timeanddate( wfTimestamp( TS_MW, $now ), true ) ] ]; $this->assertEquals( $errors, - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'tester', $this->user, $this->title ) ); } @@ -1243,7 +1360,7 @@ class PermissionManagerTest extends MediaWikiLangTestCase { //$this->assertSame( '', $user->blockedBy(), 'sanity check' ); //$this->assertSame( '', $user->blockedFor(), 'sanity check' ); //$this->assertFalse( (bool)$user->isHidden(), 'sanity check' ); - $this->assertFalse( $this->permissionManager + $this->assertFalse( MediaWikiServices::getInstance()->getPermissionManager() ->isBlockedFrom( $user, $ut ), 'sanity check' ); // Block the user @@ -1264,7 +1381,8 @@ class PermissionManagerTest extends MediaWikiLangTestCase { //$this->assertSame( $blocker->getName(), $user->blockedBy() ); //$this->assertSame( 'Because', $user->blockedFor() ); //$this->assertTrue( (bool)$user->isHidden() ); - $this->assertTrue( $this->permissionManager->isBlockedFrom( $user, $ut ) ); + $this->assertTrue( MediaWikiServices::getInstance()->getPermissionManager() + ->isBlockedFrom( $user, $ut ) ); // Unblock $block->delete(); @@ -1275,7 +1393,8 @@ class PermissionManagerTest extends MediaWikiLangTestCase { //$this->assertSame( '', $user->blockedBy() ); //$this->assertSame( '', $user->blockedFor() ); //$this->assertFalse( (bool)$user->isHidden() ); - $this->assertFalse( $this->permissionManager->isBlockedFrom( $user, $ut ) ); + $this->assertFalse( MediaWikiServices::getInstance()->getPermissionManager() + ->isBlockedFrom( $user, $ut ) ); } /** @@ -1325,7 +1444,8 @@ class PermissionManagerTest extends MediaWikiLangTestCase { $block->insert(); try { - $this->assertSame( $expect, $this->permissionManager->isBlockedFrom( $user, $title ) ); + $this->assertSame( $expect, MediaWikiServices::getInstance()->getPermissionManager() + ->isBlockedFrom( $user, $title ) ); } finally { $block->delete(); } @@ -1408,4 +1528,252 @@ class PermissionManagerTest extends MediaWikiLangTestCase { ]; } + /** + * @covers \MediaWiki\Permissions\PermissionManager::getUserPermissions + */ + public function testGetUserPermissions() { + $user = $this->getTestUser( [ 'unittesters' ] )->getUser(); + $rights = MediaWikiServices::getInstance()->getPermissionManager() + ->getUserPermissions( $user ); + $this->assertContains( 'runtest', $rights ); + $this->assertNotContains( 'writetest', $rights ); + $this->assertNotContains( 'modifytest', $rights ); + $this->assertNotContains( 'nukeworld', $rights ); + } + + /** + * @covers \MediaWiki\Permissions\PermissionManager::getUserPermissions + */ + public function testGetUserPermissionsHooks() { + $user = $this->getTestUser( [ 'unittesters', 'testwriters' ] )->getUser(); + $userWrapper = TestingAccessWrapper::newFromObject( $user ); + + $rights = MediaWikiServices::getInstance()->getPermissionManager() + ->getUserPermissions( $user ); + $this->assertContains( 'test', $rights, 'sanity check' ); + $this->assertContains( 'runtest', $rights, 'sanity check' ); + $this->assertContains( 'writetest', $rights, 'sanity check' ); + $this->assertNotContains( 'nukeworld', $rights, 'sanity check' ); + + // Add a hook manipluating the rights + $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'UserGetRights' => [ function ( $user, &$rights ) { + $rights[] = 'nukeworld'; + $rights = array_diff( $rights, [ 'writetest' ] ); + } ] ] ); + + $this->resetServices(); + $rights = MediaWikiServices::getInstance()->getPermissionManager() + ->getUserPermissions( $user ); + $this->assertContains( 'test', $rights ); + $this->assertContains( 'runtest', $rights ); + $this->assertNotContains( 'writetest', $rights ); + $this->assertContains( 'nukeworld', $rights ); + + // Add a Session that limits rights + $mock = $this->getMockBuilder( stdClass::class ) + ->setMethods( [ 'getAllowedUserRights', 'deregisterSession', 'getSessionId' ] ) + ->getMock(); + $mock->method( 'getAllowedUserRights' )->willReturn( [ 'test', 'writetest' ] ); + $mock->method( 'getSessionId' )->willReturn( + new SessionId( str_repeat( 'X', 32 ) ) + ); + $session = TestUtils::getDummySession( $mock ); + $mockRequest = $this->getMockBuilder( FauxRequest::class ) + ->setMethods( [ 'getSession' ] ) + ->getMock(); + $mockRequest->method( 'getSession' )->willReturn( $session ); + $userWrapper->mRequest = $mockRequest; + + $this->resetServices(); + $rights = MediaWikiServices::getInstance()->getPermissionManager() + ->getUserPermissions( $user ); + $this->assertContains( 'test', $rights ); + $this->assertNotContains( 'runtest', $rights ); + $this->assertNotContains( 'writetest', $rights ); + $this->assertNotContains( 'nukeworld', $rights ); + } + + /** + * @covers \MediaWiki\Permissions\PermissionManager::getGroupPermissions + */ + public function testGroupPermissions() { + $rights = MediaWikiServices::getInstance()->getPermissionManager() + ->getGroupPermissions( [ 'unittesters' ] ); + $this->assertContains( 'runtest', $rights ); + $this->assertNotContains( 'writetest', $rights ); + $this->assertNotContains( 'modifytest', $rights ); + $this->assertNotContains( 'nukeworld', $rights ); + + $rights = MediaWikiServices::getInstance()->getPermissionManager() + ->getGroupPermissions( [ 'unittesters', 'testwriters' ] ); + $this->assertContains( 'runtest', $rights ); + $this->assertContains( 'writetest', $rights ); + $this->assertContains( 'modifytest', $rights ); + $this->assertNotContains( 'nukeworld', $rights ); + } + + /** + * @covers \MediaWiki\Permissions\PermissionManager::getGroupPermissions + */ + public function testRevokePermissions() { + $rights = MediaWikiServices::getInstance()->getPermissionManager() + ->getGroupPermissions( [ 'unittesters', 'formertesters' ] ); + $this->assertNotContains( 'runtest', $rights ); + $this->assertNotContains( 'writetest', $rights ); + $this->assertNotContains( 'modifytest', $rights ); + $this->assertNotContains( 'nukeworld', $rights ); + } + + /** + * @dataProvider provideGetGroupsWithPermission + * @covers \MediaWiki\Permissions\PermissionManager::getGroupsWithPermission + */ + public function testGetGroupsWithPermission( $expected, $right ) { + $result = MediaWikiServices::getInstance()->getPermissionManager() + ->getGroupsWithPermission( $right ); + sort( $result ); + sort( $expected ); + + $this->assertEquals( $expected, $result, "Groups with permission $right" ); + } + + public static function provideGetGroupsWithPermission() { + return [ + [ + [ 'unittesters', 'testwriters' ], + 'test' + ], + [ + [ 'unittesters' ], + 'runtest' + ], + [ + [ 'testwriters' ], + 'writetest' + ], + [ + [ 'testwriters' ], + 'modifytest' + ], + ]; + } + + /** + * @covers \MediaWiki\Permissions\PermissionManager::userHasRight + */ + public function testUserHasRight() { + $result = MediaWikiServices::getInstance()->getPermissionManager()->userHasRight( + $this->getTestUser( 'unittesters' )->getUser(), + 'test' + ); + $this->assertTrue( $result ); + + $result = MediaWikiServices::getInstance()->getPermissionManager()->userHasRight( + $this->getTestUser( 'formertesters' )->getUser(), + 'runtest' + ); + $this->assertFalse( $result ); + + $result = MediaWikiServices::getInstance()->getPermissionManager()->userHasRight( + $this->getTestUser( 'formertesters' )->getUser(), + '' + ); + $this->assertTrue( $result ); + } + + /** + * @covers \MediaWiki\Permissions\PermissionManager::groupHasPermission + */ + public function testGroupHasPermission() { + $result = MediaWikiServices::getInstance()->getPermissionManager()->groupHasPermission( + 'unittesters', + 'test' + ); + $this->assertTrue( $result ); + + $result = MediaWikiServices::getInstance()->getPermissionManager()->groupHasPermission( + 'formertesters', + 'runtest' + ); + $this->assertFalse( $result ); + } + + /** + * @covers \MediaWiki\Permissions\PermissionManager::isEveryoneAllowed + */ + public function testIsEveryoneAllowed() { + $result = MediaWikiServices::getInstance()->getPermissionManager() + ->isEveryoneAllowed( 'editmyoptions' ); + $this->assertTrue( $result ); + + $result = MediaWikiServices::getInstance()->getPermissionManager() + ->isEveryoneAllowed( 'test' ); + $this->assertFalse( $result ); + } + + /** + * @covers \MediaWiki\Permissions\PermissionManager::addTemporaryUserRights + */ + public function testAddTemporaryUserRights() { + $permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); + $this->overrideUserPermissions( $this->user, [ 'read', 'edit' ] ); + // sanity checks + $this->assertEquals( [ 'read', 'edit' ], $permissionManager->getUserPermissions( $this->user ) ); + $this->assertFalse( $permissionManager->userHasRight( $this->user, 'move' ) ); + + $scope = $permissionManager->addTemporaryUserRights( $this->user, [ 'move', 'delete' ] ); + $this->assertEquals( [ 'read', 'edit', 'move', 'delete' ], + $permissionManager->getUserPermissions( $this->user ) ); + $this->assertTrue( $permissionManager->userHasRight( $this->user, 'move' ) ); + + $scope2 = $permissionManager->addTemporaryUserRights( $this->user, [ 'delete', 'upload' ] ); + $this->assertEquals( [ 'read', 'edit', 'move', 'delete', 'upload' ], + $permissionManager->getUserPermissions( $this->user ) ); + + ScopedCallback::consume( $scope ); + $this->assertEquals( [ 'read', 'edit', 'delete', 'upload' ], + $permissionManager->getUserPermissions( $this->user ) ); + ScopedCallback::consume( $scope2 ); + $this->assertEquals( [ 'read', 'edit' ], + $permissionManager->getUserPermissions( $this->user ) ); + $this->assertFalse( $permissionManager->userHasRight( $this->user, 'move' ) ); + + ( function () use ( $permissionManager ) { + $scope = $permissionManager->addTemporaryUserRights( $this->user, 'move' ); + $this->assertTrue( $permissionManager->userHasRight( $this->user, 'move' ) ); + } )(); + $this->assertFalse( $permissionManager->userHasRight( $this->user, 'move' ) ); + } + + /** + * Create a RevisionRecord with a single Javascript main slot. + * @param Title $title + * @param User $user + * @param string $text + * @return MutableRevisionRecord + */ + private function getJavascriptRevision( Title $title, User $user, $text ) { + $content = ContentHandler::makeContent( $text, $title, CONTENT_MODEL_JAVASCRIPT ); + $revision = new MutableRevisionRecord( $title ); + $revision->setContent( 'main', $content ); + return $revision; + } + + /** + * Create a RevisionRecord with a single Javascript redirect main slot. + * @param Title $title + * @param Title $redirectTargetTitle + * @param User $user + * @return MutableRevisionRecord + */ + private function getJavascriptRedirectRevision( + Title $title, Title $redirectTargetTitle, User $user + ) { + $content = ContentHandler::getForModelID( CONTENT_MODEL_JAVASCRIPT ) + ->makeRedirectContent( $redirectTargetTitle ); + $revision = new MutableRevisionRecord( $title ); + $revision->setContent( 'main', $content ); + return $revision; + } + } diff --git a/tests/phpunit/includes/Rest/BasicAccess/MWBasicRequestAuthorizerTest.php b/tests/phpunit/includes/Rest/BasicAccess/MWBasicRequestAuthorizerTest.php new file mode 100644 index 0000000000..076ff3643b --- /dev/null +++ b/tests/phpunit/includes/Rest/BasicAccess/MWBasicRequestAuthorizerTest.php @@ -0,0 +1,110 @@ +testUser = $user; + $this->testUserRights = $userRights; + } + + public function userHasRight( UserIdentity $user, $action = '' ) { + if ( $user === $this->testUser ) { + return $this->testUserRights[$action] ?? false; + } + return parent::userHasRight( $user, $action ); + } + }; + + global $IP; + + return new Router( + [ "$IP/tests/phpunit/unit/includes/Rest/testRoutes.json" ], + [], + '/rest', + new \EmptyBagOStuff(), + new ResponseFactory(), + new MWBasicAuthorizer( $user, $pm ) ); + } + + public function testReadDenied() { + $router = $this->createRouter( [ 'read' => false ] ); + $request = new RequestData( [ 'uri' => new Uri( '/rest/user/joe/hello' ) ] ); + $response = $router->execute( $request ); + $this->assertSame( 403, $response->getStatusCode() ); + + $body = $response->getBody(); + $body->rewind(); + $data = json_decode( $body->getContents(), true ); + $this->assertSame( 'rest-read-denied', $data['error'] ); + } + + public function testReadAllowed() { + $router = $this->createRouter( [ 'read' => true ] ); + $request = new RequestData( [ 'uri' => new Uri( '/rest/user/joe/hello' ) ] ); + $response = $router->execute( $request ); + $this->assertSame( 200, $response->getStatusCode() ); + } + + public static function writeHandlerFactory() { + return new class extends Handler { + public function needsWriteAccess() { + return true; + } + + public function execute() { + return ''; + } + }; + } + + public function testWriteDenied() { + $router = $this->createRouter( [ 'read' => true, 'writeapi' => false ] ); + $request = new RequestData( [ + 'uri' => new Uri( '/rest/mock/MWBasicRequestAuthorizerTest/write' ) + ] ); + $response = $router->execute( $request ); + $this->assertSame( 403, $response->getStatusCode() ); + + $body = $response->getBody(); + $body->rewind(); + $data = json_decode( $body->getContents(), true ); + $this->assertSame( 'rest-write-denied', $data['error'] ); + } + + public function testWriteAllowed() { + $router = $this->createRouter( [ 'read' => true, 'writeapi' => true ] ); + $request = new RequestData( [ + 'uri' => new Uri( '/rest/mock/MWBasicRequestAuthorizerTest/write' ) + ] ); + $response = $router->execute( $request ); + + $this->assertSame( 200, $response->getStatusCode() ); + } +} diff --git a/tests/phpunit/includes/Revision/FallbackSlotRoleHandlerTest.php b/tests/phpunit/includes/Revision/FallbackSlotRoleHandlerTest.php deleted file mode 100644 index aedf292eca..0000000000 --- a/tests/phpunit/includes/Revision/FallbackSlotRoleHandlerTest.php +++ /dev/null @@ -1,75 +0,0 @@ -getMockBuilder( Title::class ) - ->disableOriginalConstructor() - ->getMock(); - - return $title; - } - - /** - * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::__construct - * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::getRole() - * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::getNameMessageKey() - * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::getDefaultModel() - * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::getOutputLayoutHints() - */ - public function testConstruction() { - $handler = new FallbackSlotRoleHandler( 'foo' ); - $this->assertSame( 'foo', $handler->getRole() ); - $this->assertSame( 'slot-name-foo', $handler->getNameMessageKey() ); - - $title = $this->makeBlankTitleObject(); - $this->assertSame( CONTENT_MODEL_TEXT, $handler->getDefaultModel( $title ) ); - - $hints = $handler->getOutputLayoutHints(); - $this->assertArrayHasKey( 'display', $hints ); - $this->assertArrayHasKey( 'region', $hints ); - $this->assertArrayHasKey( 'placement', $hints ); - } - - /** - * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::isAllowedModel() - */ - public function testIsAllowedModel() { - $handler = new FallbackSlotRoleHandler( 'foo', 'FooModel' ); - - // For the fallback handler, no models are allowed - $title = $this->makeBlankTitleObject(); - $this->assertFalse( $handler->isAllowedModel( 'FooModel', $title ) ); - $this->assertFalse( $handler->isAllowedModel( 'QuaxModel', $title ) ); - } - - /** - * @covers \MediaWiki\Revision\SlotRoleHandler::isAllowedModel() - */ - public function testIsAllowedOn() { - $handler = new FallbackSlotRoleHandler( 'foo', 'FooModel' ); - - $title = $this->makeBlankTitleObject(); - $this->assertFalse( $handler->isAllowedOn( $title ) ); - } - - /** - * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::supportsArticleCount() - */ - public function testSupportsArticleCount() { - $handler = new FallbackSlotRoleHandler( 'foo', 'FooModel' ); - - $this->assertFalse( $handler->supportsArticleCount() ); - } - -} diff --git a/tests/phpunit/includes/Revision/MainSlotRoleHandlerTest.php b/tests/phpunit/includes/Revision/MainSlotRoleHandlerTest.php deleted file mode 100644 index 5e32574d40..0000000000 --- a/tests/phpunit/includes/Revision/MainSlotRoleHandlerTest.php +++ /dev/null @@ -1,79 +0,0 @@ -getMockBuilder( Title::class ) - ->disableOriginalConstructor() - ->getMock(); - - $title->method( 'getNamespace' ) - ->willReturn( $ns ); - - return $title; - } - - /** - * @covers \MediaWiki\Revision\MainSlotRoleHandler::__construct - * @covers \MediaWiki\Revision\MainSlotRoleHandler::getRole() - * @covers \MediaWiki\Revision\MainSlotRoleHandler::getNameMessageKey() - * @covers \MediaWiki\Revision\MainSlotRoleHandler::getOutputLayoutHints() - */ - public function testConstruction() { - $handler = new MainSlotRoleHandler( [] ); - $this->assertSame( 'main', $handler->getRole() ); - $this->assertSame( 'slot-name-main', $handler->getNameMessageKey() ); - - $hints = $handler->getOutputLayoutHints(); - $this->assertArrayHasKey( 'display', $hints ); - $this->assertArrayHasKey( 'region', $hints ); - $this->assertArrayHasKey( 'placement', $hints ); - } - - /** - * @covers \MediaWiki\Revision\MainSlotRoleHandler::getDefaultModel() - */ - public function testFetDefaultModel() { - $handler = new MainSlotRoleHandler( [ 100 => CONTENT_MODEL_TEXT ] ); - - // For the main handler, the namespace determins the default model - $titleMain = $this->makeTitleObject( NS_MAIN ); - $this->assertSame( CONTENT_MODEL_WIKITEXT, $handler->getDefaultModel( $titleMain ) ); - - $title100 = $this->makeTitleObject( 100 ); - $this->assertSame( CONTENT_MODEL_TEXT, $handler->getDefaultModel( $title100 ) ); - } - - /** - * @covers \MediaWiki\Revision\MainSlotRoleHandler::isAllowedModel() - */ - public function testIsAllowedModel() { - $handler = new MainSlotRoleHandler( [] ); - - // For the main handler, (nearly) all models are allowed - $title = $this->makeTitleObject( NS_MAIN ); - $this->assertTrue( $handler->isAllowedModel( CONTENT_MODEL_WIKITEXT, $title ) ); - $this->assertTrue( $handler->isAllowedModel( CONTENT_MODEL_TEXT, $title ) ); - } - - /** - * @covers \MediaWiki\Revision\MainSlotRoleHandler::supportsArticleCount() - */ - public function testSupportsArticleCount() { - $handler = new MainSlotRoleHandler( [] ); - - $this->assertTrue( $handler->supportsArticleCount() ); - } - -} diff --git a/tests/phpunit/includes/Revision/McrReadNewRevisionStoreDbTest.php b/tests/phpunit/includes/Revision/McrReadNewRevisionStoreDbTest.php index fed47f061f..43a698a869 100644 --- a/tests/phpunit/includes/Revision/McrReadNewRevisionStoreDbTest.php +++ b/tests/phpunit/includes/Revision/McrReadNewRevisionStoreDbTest.php @@ -144,4 +144,14 @@ class McrReadNewRevisionStoreDbTest extends RevisionStoreDbTestBase { ]; } + /** + * Conditions to use together with getSlotsQueryInfo() when selecting slot rows for a given + * revision. + * + * @return array + */ + protected function getSlotRevisionConditions( $revId ) { + return [ 'slot_revision_id' => $revId ]; + } + } diff --git a/tests/phpunit/includes/Revision/McrRevisionStoreDbTest.php b/tests/phpunit/includes/Revision/McrRevisionStoreDbTest.php index 0aa220c5da..7d301a9c44 100644 --- a/tests/phpunit/includes/Revision/McrRevisionStoreDbTest.php +++ b/tests/phpunit/includes/Revision/McrRevisionStoreDbTest.php @@ -187,4 +187,14 @@ class McrRevisionStoreDbTest extends RevisionStoreDbTestBase { $this->assertRevisionRecordsEqual( $return, $loaded ); } + /** + * Conditions to use together with getSlotsQueryInfo() when selecting slot rows for a given + * revision. + * + * @return array + */ + protected function getSlotRevisionConditions( $revId ) { + return [ 'slot_revision_id' => $revId ]; + } + } diff --git a/tests/phpunit/includes/Revision/McrWriteBothRevisionStoreDbTest.php b/tests/phpunit/includes/Revision/McrWriteBothRevisionStoreDbTest.php index 856c343371..8c0960bd38 100644 --- a/tests/phpunit/includes/Revision/McrWriteBothRevisionStoreDbTest.php +++ b/tests/phpunit/includes/Revision/McrWriteBothRevisionStoreDbTest.php @@ -183,4 +183,14 @@ class McrWriteBothRevisionStoreDbTest extends RevisionStoreDbTestBase { $this->assertRevisionExistsInDatabase( $return ); } + /** + * Conditions to use together with getSlotsQueryInfo() when selecting slot rows for a given + * revision. + * + * @return array + */ + protected function getSlotRevisionConditions( $revId ) { + return [ 'rev_id' => $revId ]; + } + } diff --git a/tests/phpunit/includes/Revision/NoContentModelRevisionStoreDbTest.php b/tests/phpunit/includes/Revision/NoContentModelRevisionStoreDbTest.php index 1250a6b034..51cfc63763 100644 --- a/tests/phpunit/includes/Revision/NoContentModelRevisionStoreDbTest.php +++ b/tests/phpunit/includes/Revision/NoContentModelRevisionStoreDbTest.php @@ -189,4 +189,14 @@ class NoContentModelRevisionStoreDbTest extends RevisionStoreDbTestBase { ]; } + /** + * Conditions to use together with getSlotsQueryInfo() when selecting slot rows for a given + * revision. + * + * @return array + */ + protected function getSlotRevisionConditions( $revId ) { + return [ 'rev_id' => $revId ]; + } + } diff --git a/tests/phpunit/includes/Revision/PreMcrRevisionStoreDbTest.php b/tests/phpunit/includes/Revision/PreMcrRevisionStoreDbTest.php index 011c79e22f..468ab60800 100644 --- a/tests/phpunit/includes/Revision/PreMcrRevisionStoreDbTest.php +++ b/tests/phpunit/includes/Revision/PreMcrRevisionStoreDbTest.php @@ -89,4 +89,14 @@ class PreMcrRevisionStoreDbTest extends RevisionStoreDbTestBase { ]; } + /** + * Conditions to use together with getSlotsQueryInfo() when selecting slot rows for a given + * revision. + * + * @return array + */ + protected function getSlotRevisionConditions( $revId ) { + return [ 'rev_id' => $revId ]; + } + } diff --git a/tests/phpunit/includes/Revision/RevisionQueryInfoTest.php b/tests/phpunit/includes/Revision/RevisionQueryInfoTest.php index 35bc917ba6..57619c55b6 100644 --- a/tests/phpunit/includes/Revision/RevisionQueryInfoTest.php +++ b/tests/phpunit/includes/Revision/RevisionQueryInfoTest.php @@ -731,13 +731,13 @@ class RevisionQueryInfoTest extends MediaWikiTestCase { [], [ 'tables' => [ - 'slots' => 'revision', + 'revision', ], 'fields' => array_merge( [ - 'slot_revision_id' => 'slots.rev_id', + 'slot_revision_id' => 'rev_id', 'slot_content_id' => 'NULL', - 'slot_origin' => 'slots.rev_id', + 'slot_origin' => 'rev_id', 'role_name' => $db->addQuotes( SlotRecord::MAIN ), ] ), @@ -752,19 +752,20 @@ class RevisionQueryInfoTest extends MediaWikiTestCase { [ 'content' ], [ 'tables' => [ - 'slots' => 'revision', + 'revision', ], 'fields' => array_merge( [ - 'slot_revision_id' => 'slots.rev_id', + 'slot_revision_id' => 'rev_id', 'slot_content_id' => 'NULL', - 'slot_origin' => 'slots.rev_id', + 'slot_origin' => 'rev_id', 'role_name' => $db->addQuotes( SlotRecord::MAIN ), - 'content_size' => 'slots.rev_len', - 'content_sha1' => 'slots.rev_sha1', + 'content_size' => 'rev_len', + 'content_sha1' => 'rev_sha1', 'content_address' => $db->buildConcat( [ - $db->addQuotes( 'tt:' ), 'slots.rev_text_id' ] ), - 'model_name' => 'slots.rev_content_model', + $db->addQuotes( 'tt:' ), 'rev_text_id' ] ), + 'rev_text_id' => 'rev_text_id', + 'model_name' => 'rev_content_model', ] ), 'joins' => [], @@ -778,19 +779,20 @@ class RevisionQueryInfoTest extends MediaWikiTestCase { [ 'content', 'model', 'role' ], [ 'tables' => [ - 'slots' => 'revision', + 'revision', ], 'fields' => array_merge( [ - 'slot_revision_id' => 'slots.rev_id', + 'slot_revision_id' => 'rev_id', 'slot_content_id' => 'NULL', - 'slot_origin' => 'slots.rev_id', + 'slot_origin' => 'rev_id', 'role_name' => $db->addQuotes( SlotRecord::MAIN ), - 'content_size' => 'slots.rev_len', - 'content_sha1' => 'slots.rev_sha1', + 'content_size' => 'rev_len', + 'content_sha1' => 'rev_sha1', 'content_address' => $db->buildConcat( [ - $db->addQuotes( 'tt:' ), 'slots.rev_text_id' ] ), - 'model_name' => 'slots.rev_content_model', + $db->addQuotes( 'tt:' ), 'rev_text_id' ] ), + 'rev_text_id' => 'rev_text_id', + 'model_name' => 'rev_content_model', ] ), 'joins' => [], @@ -804,13 +806,13 @@ class RevisionQueryInfoTest extends MediaWikiTestCase { [], [ 'tables' => [ - 'slots' => 'revision', + 'revision', ], 'fields' => array_merge( [ - 'slot_revision_id' => 'slots.rev_id', + 'slot_revision_id' => 'rev_id', 'slot_content_id' => 'NULL', - 'slot_origin' => 'slots.rev_id', + 'slot_origin' => 'rev_id', 'role_name' => $db->addQuotes( SlotRecord::MAIN ), ] ), @@ -825,19 +827,20 @@ class RevisionQueryInfoTest extends MediaWikiTestCase { [ 'content' ], [ 'tables' => [ - 'slots' => 'revision', + 'revision', ], 'fields' => array_merge( [ - 'slot_revision_id' => 'slots.rev_id', + 'slot_revision_id' => 'rev_id', 'slot_content_id' => 'NULL', - 'slot_origin' => 'slots.rev_id', + 'slot_origin' => 'rev_id', 'role_name' => $db->addQuotes( SlotRecord::MAIN ), - 'content_size' => 'slots.rev_len', - 'content_sha1' => 'slots.rev_sha1', + 'content_size' => 'rev_len', + 'content_sha1' => 'rev_sha1', 'content_address' => - $db->buildConcat( [ $db->addQuotes( 'tt:' ), 'slots.rev_text_id' ] ), - 'model_name' => 'slots.rev_content_model', + $db->buildConcat( [ $db->addQuotes( 'tt:' ), 'rev_text_id' ] ), + 'rev_text_id' => 'rev_text_id', + 'model_name' => 'rev_content_model', ] ), 'joins' => [], diff --git a/tests/phpunit/includes/Revision/RevisionRendererTest.php b/tests/phpunit/includes/Revision/RevisionRendererTest.php index 071ea68347..d1418c28c8 100644 --- a/tests/phpunit/includes/Revision/RevisionRendererTest.php +++ b/tests/phpunit/includes/Revision/RevisionRendererTest.php @@ -19,7 +19,6 @@ use ParserOptions; use ParserOutput; use PHPUnit\Framework\MockObject\MockObject; use Title; -use User; use Wikimedia\Rdbms\IDatabase; use Wikimedia\Rdbms\ILoadBalancer; use WikitextContent; @@ -74,12 +73,8 @@ class RevisionRendererTest extends MediaWikiTestCase { } ); $mock->expects( $this->any() ) - ->method( 'userCan' ) - ->willReturnCallback( - function ( $perm, User $user ) use ( $mock ) { - return $user->isAllowed( $perm ); - } - ); + ->method( 'getRestrictions' ) + ->willReturn( [] ); return $mock; } @@ -368,6 +363,7 @@ class RevisionRendererTest extends MediaWikiTestCase { $sysop = $this->getTestUser( [ 'sysop' ] )->getUser(); // privileged! $rr = $renderer->getRenderedRevision( $rev, $options, $sysop ); + $this->assertNotNull( $rr, 'getRenderedRevision' ); $this->assertTrue( $rr->isContentDeleted(), 'isContentDeleted' ); $this->assertSame( $rev, $rr->getRevision() ); diff --git a/tests/phpunit/includes/Revision/RevisionStoreDbTestBase.php b/tests/phpunit/includes/Revision/RevisionStoreDbTestBase.php index 3467153a55..d4393dd3f6 100644 --- a/tests/phpunit/includes/Revision/RevisionStoreDbTestBase.php +++ b/tests/phpunit/includes/Revision/RevisionStoreDbTestBase.php @@ -185,7 +185,7 @@ abstract class RevisionStoreDbTestBase extends MediaWikiTestCase { /** * @dataProvider provideDomainCheck - * @covers \MediaWiki\Revision\RevisionStore::checkDatabaseWikiId + * @covers \MediaWiki\Revision\RevisionStore::checkDatabaseDomain */ public function testDomainCheck( $wikiId, $dbName, $dbPrefix ) { $this->setMwGlobals( @@ -897,12 +897,71 @@ abstract class RevisionStoreDbTestBase extends MediaWikiTestCase { } if ( $revMain->hasContentId() ) { - $this->assertSame( $revMain->getContentId(), $recMain->getContentId(), 'getContentId' ); + // XXX: the content ID value is ill-defined when SCHEMA_COMPAT_WRITE_BOTH and + // SCHEMA_COMPAT_READ_OLD is set, since revision insertion will report the + // content ID used with the new schema, while loading the revision from the + // old schema will report an emulated ID. + if ( $this->getMcrMigrationStage() & SCHEMA_COMPAT_READ_NEW ) { + $this->assertSame( $revMain->getContentId(), $recMain->getContentId(), 'getContentId' ); + } } } + /** + * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRowAndSlots + * @covers \MediaWiki\Revision\RevisionStore::getQueryInfo + */ + public function testNewRevisionFromRowAndSlot_getQueryInfo() { + $page = $this->getTestPage(); + $text = __METHOD__ . 'o-ö'; + /** @var Revision $rev */ + $rev = $page->doEditContent( + new WikitextContent( $text ), + __METHOD__ . 'a' + )->value['revision']; + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $info = $store->getQueryInfo(); + $row = $this->db->selectRow( + $info['tables'], + $info['fields'], + [ 'rev_id' => $rev->getId() ], + __METHOD__, + [], + $info['joins'] + ); + + $info = $store->getSlotsQueryInfo( [ 'content' ] ); + $slotRows = $this->db->select( + $info['tables'], + $info['fields'], + $this->getSlotRevisionConditions( $rev->getId() ), + __METHOD__, + [], + $info['joins'] + ); + + $record = $store->newRevisionFromRowAndSlots( + $row, + iterator_to_array( $slotRows ), + [], + $page->getTitle() + ); + $this->assertRevisionRecordMatchesRevision( $rev, $record ); + $this->assertSame( $text, $rev->getContent()->serialize() ); + } + + /** + * Conditions to use together with getSlotsQueryInfo() when selecting slot rows for a given + * revision. + * + * @return array + */ + abstract protected function getSlotRevisionConditions( $revId ); + /** * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRow + * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRowAndSlots * @covers \MediaWiki\Revision\RevisionStore::getQueryInfo */ public function testNewRevisionFromRow_getQueryInfo() { @@ -935,6 +994,7 @@ abstract class RevisionStoreDbTestBase extends MediaWikiTestCase { /** * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRow + * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRowAndSlots */ public function testNewRevisionFromRow_anonEdit() { $page = $this->getTestPage(); @@ -957,6 +1017,7 @@ abstract class RevisionStoreDbTestBase extends MediaWikiTestCase { /** * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRow + * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRowAndSlots */ public function testNewRevisionFromRow_anonEdit_legacyEncoding() { $this->setMwGlobals( 'wgLegacyEncoding', 'windows-1252' ); @@ -981,6 +1042,7 @@ abstract class RevisionStoreDbTestBase extends MediaWikiTestCase { /** * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRow + * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRowAndSlots */ public function testNewRevisionFromRow_userEdit() { $page = $this->getTestPage(); @@ -1105,6 +1167,7 @@ abstract class RevisionStoreDbTestBase extends MediaWikiTestCase { /** * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRow + * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRowAndSlots */ public function testNewRevisionFromRow_no_user() { $store = MediaWikiServices::getInstance()->getRevisionStore(); @@ -1690,8 +1753,10 @@ abstract class RevisionStoreDbTestBase extends MediaWikiTestCase { */ public function testNewMutableRevisionFromArray_legacyEncoding( array $array ) { $cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] ); - $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); - $blobStore = new SqlBlobStore( $lb, $cache ); + $services = MediaWikiServices::getInstance(); + $lb = $services->getDBLoadBalancer(); + $access = $services->getExternalStoreAccess(); + $blobStore = new SqlBlobStore( $lb, $access, $cache ); $blobStore->setLegacyEncoding( 'windows-1252', Language::factory( 'en' ) ); $factory = $this->getMockBuilder( BlobStoreFactory::class ) diff --git a/tests/phpunit/includes/Revision/RevisionStoreFactoryTest.php b/tests/phpunit/includes/Revision/RevisionStoreFactoryTest.php index 138d6bcba1..f4d324dde7 100644 --- a/tests/phpunit/includes/Revision/RevisionStoreFactoryTest.php +++ b/tests/phpunit/includes/Revision/RevisionStoreFactoryTest.php @@ -13,7 +13,6 @@ use MediaWiki\Storage\BlobStoreFactory; use MediaWiki\Storage\NameTableStore; use MediaWiki\Storage\NameTableStoreFactory; use MediaWiki\Storage\SqlBlobStore; -use MediaWikiTestCase; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use WANObjectCache; @@ -21,7 +20,7 @@ use Wikimedia\Rdbms\ILBFactory; use Wikimedia\Rdbms\ILoadBalancer; use Wikimedia\TestingAccessWrapper; -class RevisionStoreFactoryTest extends MediaWikiTestCase { +class RevisionStoreFactoryTest extends \MediaWikiIntegrationTestCase { /** * @covers \MediaWiki\Revision\RevisionStoreFactory::__construct @@ -55,7 +54,7 @@ class RevisionStoreFactoryTest extends MediaWikiTestCase { * @covers \MediaWiki\Revision\RevisionStoreFactory::getRevisionStore */ public function testGetRevisionStore( - $wikiId, + $dbDomain, $mcrMigrationStage = MIGRATION_OLD, $contentHandlerUseDb = true ) { @@ -81,14 +80,14 @@ class RevisionStoreFactoryTest extends MediaWikiTestCase { $contentHandlerUseDb ); - $store = $factory->getRevisionStore( $wikiId ); + $store = $factory->getRevisionStore( $dbDomain ); $wrapper = TestingAccessWrapper::newFromObject( $store ); // ensure the correct object type is returned $this->assertInstanceOf( RevisionStore::class, $store ); // ensure the RevisionStore is for the given wikiId - $this->assertSame( $wikiId, $wrapper->wikiId ); + $this->assertSame( $dbDomain, $wrapper->dbDomain ); // ensure all other required services are correctly set $this->assertSame( $cache, $wrapper->cache ); @@ -151,13 +150,10 @@ class RevisionStoreFactoryTest extends MediaWikiTestCase { } /** - * @return \PHPUnit_Framework_MockObject_MockObject|SlotRoleRegistry + * @return SlotRoleRegistry */ private function getMockSlotRoleRegistry() { - $mock = $this->getMockBuilder( SlotRoleRegistry::class ) - ->disableOriginalConstructor()->getMock(); - - return $mock; + return $this->createMock( SlotRoleRegistry::class ); } /** diff --git a/tests/phpunit/includes/Revision/RevisionStoreTest.php b/tests/phpunit/includes/Revision/RevisionStoreTest.php index 5246e36832..83872e3a37 100644 --- a/tests/phpunit/includes/Revision/RevisionStoreTest.php +++ b/tests/phpunit/includes/Revision/RevisionStoreTest.php @@ -12,11 +12,13 @@ use MediaWiki\Revision\RevisionStore; use MediaWiki\Revision\SlotRoleRegistry; use MediaWiki\Revision\SlotRecord; use MediaWiki\Storage\SqlBlobStore; +use Wikimedia\Rdbms\ILoadBalancer; +use Wikimedia\Rdbms\MaintainableDBConnRef; use MediaWikiTestCase; use MWException; use Title; use WANObjectCache; -use Wikimedia\Rdbms\Database; +use Wikimedia\Rdbms\IDatabase; use Wikimedia\Rdbms\LoadBalancer; use Wikimedia\TestingAccessWrapper; use WikitextContent; @@ -70,13 +72,24 @@ class RevisionStoreTest extends MediaWikiTestCase { } /** - * @return \PHPUnit_Framework_MockObject_MockObject|Database + * @return \PHPUnit_Framework_MockObject_MockObject|IDatabase */ private function getMockDatabase() { - return $this->getMockBuilder( Database::class ) + return $this->getMockBuilder( IDatabase::class ) ->disableOriginalConstructor()->getMock(); } + /** + * @param ILoadBalancer $mockLoadBalancer + * @param Database $db + * @return callable + */ + private function getMockDBConnRefCallback( ILoadBalancer $mockLoadBalancer, IDatabase $db ) { + return function ( $i, $g, $domain, $flg ) use ( $mockLoadBalancer, $db ) { + return new MaintainableDBConnRef( $mockLoadBalancer, $db, $i ); + }; + } + /** * @return \PHPUnit_Framework_MockObject_MockObject|SqlBlobStore */ @@ -158,10 +171,14 @@ class RevisionStoreTest extends MediaWikiTestCase { $this->setService( 'DBLoadBalancer', $mockLoadBalancer ); $db = $this->getMockDatabase(); - // Title calls wfGetDB() which uses a regular Connection + // RevisionStore uses getConnectionRef + $mockLoadBalancer->expects( $this->any() ) + ->method( 'getConnectionRef' ) + ->willReturnCallback( $this->getMockDBConnRefCallback( $mockLoadBalancer, $db ) ); + // Title calls wfGetDB() which uses getMaintenanceConnectionRef $mockLoadBalancer->expects( $this->atLeastOnce() ) - ->method( 'getConnection' ) - ->willReturn( $db ); + ->method( 'getMaintenanceConnectionRef' ) + ->willReturnCallback( $this->getMockDBConnRefCallback( $mockLoadBalancer, $db ) ); // First call to Title::newFromID, faking no result (db lag?) $db->expects( $this->at( 0 ) ) @@ -192,15 +209,15 @@ class RevisionStoreTest extends MediaWikiTestCase { $this->setService( 'DBLoadBalancer', $mockLoadBalancer ); $db = $this->getMockDatabase(); - // Title calls wfGetDB() which uses a regular Connection + // Title calls wfGetDB() which uses getMaintenanceConnectionRef // Assert that the first call uses a REPLICA and the second falls back to master - $mockLoadBalancer->expects( $this->exactly( 2 ) ) - ->method( 'getConnection' ) - ->willReturn( $db ); - // RevisionStore getTitle uses a ConnectionRef $mockLoadBalancer->expects( $this->atLeastOnce() ) ->method( 'getConnectionRef' ) - ->willReturn( $db ); + ->willReturnCallback( $this->getMockDBConnRefCallback( $mockLoadBalancer, $db ) ); + // Title calls wfGetDB() which uses getMaintenanceConnectionRef + $mockLoadBalancer->expects( $this->exactly( 2 ) ) + ->method( 'getMaintenanceConnectionRef' ) + ->willReturnCallback( $this->getMockDBConnRefCallback( $mockLoadBalancer, $db ) ); // First call to Title::newFromID, faking no result (db lag?) $db->expects( $this->at( 0 ) ) @@ -251,14 +268,14 @@ class RevisionStoreTest extends MediaWikiTestCase { $this->setService( 'DBLoadBalancer', $mockLoadBalancer ); $db = $this->getMockDatabase(); - // Title calls wfGetDB() which uses a regular Connection - $mockLoadBalancer->expects( $this->atLeastOnce() ) - ->method( 'getConnection' ) - ->willReturn( $db ); - // RevisionStore getTitle uses a ConnectionRef $mockLoadBalancer->expects( $this->atLeastOnce() ) ->method( 'getConnectionRef' ) - ->willReturn( $db ); + ->willReturnCallback( $this->getMockDBConnRefCallback( $mockLoadBalancer, $db ) ); + // Title calls wfGetDB() which uses getMaintenanceConnectionRef + // RevisionStore getTitle uses getMaintenanceConnectionRef + $mockLoadBalancer->expects( $this->atLeastOnce() ) + ->method( 'getMaintenanceConnectionRef' ) + ->willReturnCallback( $this->getMockDBConnRefCallback( $mockLoadBalancer, $db ) ); // First call to Title::newFromID, faking no result (db lag?) $db->expects( $this->at( 0 ) ) @@ -299,15 +316,15 @@ class RevisionStoreTest extends MediaWikiTestCase { $this->setService( 'DBLoadBalancer', $mockLoadBalancer ); $db = $this->getMockDatabase(); - // Title calls wfGetDB() which uses a regular Connection // Assert that the first call uses a REPLICA and the second falls back to master - $mockLoadBalancer->expects( $this->exactly( 2 ) ) - ->method( 'getConnection' ) - ->willReturn( $db ); - // RevisionStore getTitle uses a ConnectionRef + // RevisionStore uses getMaintenanceConnectionRef $mockLoadBalancer->expects( $this->atLeastOnce() ) ->method( 'getConnectionRef' ) - ->willReturn( $db ); + ->willReturnCallback( $this->getMockDBConnRefCallback( $mockLoadBalancer, $db ) ); + // Title calls wfGetDB() which uses getMaintenanceConnectionRef + $mockLoadBalancer->expects( $this->exactly( 2 ) ) + ->method( 'getMaintenanceConnectionRef' ) + ->willReturnCallback( $this->getMockDBConnRefCallback( $mockLoadBalancer, $db ) ); // First call to Title::newFromID, faking no result (db lag?) $db->expects( $this->at( 0 ) ) @@ -368,12 +385,14 @@ class RevisionStoreTest extends MediaWikiTestCase { $this->setService( 'DBLoadBalancer', $mockLoadBalancer ); $db = $this->getMockDatabase(); - // Title calls wfGetDB() which uses a regular Connection + // Title calls wfGetDB() which uses getMaintenanceConnectionRef // Assert that the first call uses a REPLICA and the second falls back to master // RevisionStore getTitle uses getConnectionRef - // Title::newFromID uses getConnection - foreach ( [ 'getConnection', 'getConnectionRef' ] as $method ) { + // Title::newFromID uses getMaintenanceConnectionRef + foreach ( [ + 'getConnectionRef', 'getMaintenanceConnectionRef' + ] as $method ) { $mockLoadBalancer->expects( $this->exactly( 2 ) ) ->method( $method ) ->willReturnCallback( function ( $masterOrReplica ) use ( $db ) { @@ -450,9 +469,12 @@ class RevisionStoreTest extends MediaWikiTestCase { } $cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] ); - $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); + $services = MediaWikiServices::getInstance(); + $lb = $services->getDBLoadBalancer(); + $access = $services->getExternalStoreAccess(); + + $blobStore = new SqlBlobStore( $lb, $access, $cache ); - $blobStore = new SqlBlobStore( $lb, $cache ); $blobStore->setLegacyEncoding( $encoding, Language::factory( $locale ) ); $store = $this->getRevisionStore( $lb, $blobStore, $cache ); @@ -480,9 +502,11 @@ class RevisionStoreTest extends MediaWikiTestCase { ]; $cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] ); - $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); + $services = MediaWikiServices::getInstance(); + $lb = $services->getDBLoadBalancer(); + $access = $services->getExternalStoreAccess(); - $blobStore = new SqlBlobStore( $lb, $cache ); + $blobStore = new SqlBlobStore( $lb, $access, $cache ); $blobStore->setLegacyEncoding( 'windows-1252', Language::factory( 'en' ) ); $store = $this->getRevisionStore( $lb, $blobStore, $cache ); diff --git a/tests/phpunit/includes/Revision/SlotRecordTest.php b/tests/phpunit/includes/Revision/SlotRecordTest.php deleted file mode 100644 index 1b6ff2aace..0000000000 --- a/tests/phpunit/includes/Revision/SlotRecordTest.php +++ /dev/null @@ -1,408 +0,0 @@ - 1234, - 'slot_content_id' => 33, - 'content_size' => '5', - 'content_sha1' => 'someHash', - 'content_address' => 'tt:456', - 'model_name' => CONTENT_MODEL_WIKITEXT, - 'format_name' => CONTENT_FORMAT_WIKITEXT, - 'slot_revision_id' => '2', - 'slot_origin' => '1', - 'role_name' => 'myRole', - ]; - return (object)$data; - } - - public function testCompleteConstruction() { - $row = $this->makeRow(); - $record = new SlotRecord( $row, new WikitextContent( 'A' ) ); - - $this->assertTrue( $record->hasAddress() ); - $this->assertTrue( $record->hasContentId() ); - $this->assertTrue( $record->hasRevision() ); - $this->assertTrue( $record->isInherited() ); - $this->assertSame( 'A', $record->getContent()->getText() ); - $this->assertSame( 5, $record->getSize() ); - $this->assertSame( 'someHash', $record->getSha1() ); - $this->assertSame( CONTENT_MODEL_WIKITEXT, $record->getModel() ); - $this->assertSame( 2, $record->getRevision() ); - $this->assertSame( 1, $record->getOrigin() ); - $this->assertSame( 'tt:456', $record->getAddress() ); - $this->assertSame( 33, $record->getContentId() ); - $this->assertSame( CONTENT_FORMAT_WIKITEXT, $record->getFormat() ); - $this->assertSame( 'myRole', $record->getRole() ); - } - - public function testConstructionDeferred() { - $row = $this->makeRow( [ - 'content_size' => null, // to be computed - 'content_sha1' => null, // to be computed - 'format_name' => function () { - return CONTENT_FORMAT_WIKITEXT; - }, - 'slot_revision_id' => '2', - 'slot_origin' => '2', - 'slot_content_id' => function () { - return null; - }, - ] ); - - $content = function () { - return new WikitextContent( 'A' ); - }; - - $record = new SlotRecord( $row, $content ); - - $this->assertTrue( $record->hasAddress() ); - $this->assertTrue( $record->hasRevision() ); - $this->assertFalse( $record->hasContentId() ); - $this->assertFalse( $record->isInherited() ); - $this->assertSame( 'A', $record->getContent()->getText() ); - $this->assertSame( 1, $record->getSize() ); - $this->assertNotNull( $record->getSha1() ); - $this->assertSame( CONTENT_MODEL_WIKITEXT, $record->getModel() ); - $this->assertSame( 2, $record->getRevision() ); - $this->assertSame( 2, $record->getRevision() ); - $this->assertSame( 'tt:456', $record->getAddress() ); - $this->assertSame( CONTENT_FORMAT_WIKITEXT, $record->getFormat() ); - $this->assertSame( 'myRole', $record->getRole() ); - } - - public function testNewUnsaved() { - $record = SlotRecord::newUnsaved( 'myRole', new WikitextContent( 'A' ) ); - - $this->assertFalse( $record->hasAddress() ); - $this->assertFalse( $record->hasContentId() ); - $this->assertFalse( $record->hasRevision() ); - $this->assertFalse( $record->isInherited() ); - $this->assertFalse( $record->hasOrigin() ); - $this->assertSame( 'A', $record->getContent()->getText() ); - $this->assertSame( 1, $record->getSize() ); - $this->assertNotNull( $record->getSha1() ); - $this->assertSame( CONTENT_MODEL_WIKITEXT, $record->getModel() ); - $this->assertSame( 'myRole', $record->getRole() ); - } - - public function provideInvalidConstruction() { - yield 'both null' => [ null, null ]; - yield 'null row' => [ null, new WikitextContent( 'A' ) ]; - yield 'array row' => [ [], new WikitextContent( 'A' ) ]; - yield 'empty row' => [ (object)[], new WikitextContent( 'A' ) ]; - yield 'null content' => [ (object)[], null ]; - } - - /** - * @dataProvider provideInvalidConstruction - */ - public function testInvalidConstruction( $row, $content ) { - $this->setExpectedException( InvalidArgumentException::class ); - new SlotRecord( $row, $content ); - } - - public function testGetContentId_fails() { - $record = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); - $this->setExpectedException( IncompleteRevisionException::class ); - - $record->getContentId(); - } - - public function testGetAddress_fails() { - $record = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); - $this->setExpectedException( IncompleteRevisionException::class ); - - $record->getAddress(); - } - - public function provideIncomplete() { - $unsaved = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); - yield 'unsaved' => [ $unsaved ]; - - $parent = new SlotRecord( $this->makeRow(), new WikitextContent( 'A' ) ); - $inherited = SlotRecord::newInherited( $parent ); - yield 'inherited' => [ $inherited ]; - } - - /** - * @dataProvider provideIncomplete - */ - public function testGetRevision_fails( SlotRecord $record ) { - $record = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); - $this->setExpectedException( IncompleteRevisionException::class ); - - $record->getRevision(); - } - - /** - * @dataProvider provideIncomplete - */ - public function testGetOrigin_fails( SlotRecord $record ) { - $record = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); - $this->setExpectedException( IncompleteRevisionException::class ); - - $record->getOrigin(); - } - - public function provideHashStability() { - yield [ '', 'phoiac9h4m842xq45sp7s6u21eteeq1' ]; - yield [ 'Lorem ipsum', 'hcr5u40uxr81d3nx89nvwzclfz6r9c5' ]; - } - - /** - * @dataProvider provideHashStability - */ - public function testHashStability( $text, $hash ) { - // Changing the output of the hash function will break things horribly! - - $this->assertSame( $hash, SlotRecord::base36Sha1( $text ) ); - - $record = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( $text ) ); - $this->assertSame( $hash, $record->getSha1() ); - } - - public function testNewWithSuppressedContent() { - $input = new SlotRecord( $this->makeRow(), new WikitextContent( 'A' ) ); - $output = SlotRecord::newWithSuppressedContent( $input ); - - $this->setExpectedException( SuppressedDataException::class ); - $output->getContent(); - } - - public function testNewInherited() { - $row = $this->makeRow( [ 'slot_revision_id' => 7, 'slot_origin' => 7 ] ); - $parent = new SlotRecord( $row, new WikitextContent( 'A' ) ); - - // This would happen while doing an edit, before saving revision meta-data. - $inherited = SlotRecord::newInherited( $parent ); - - $this->assertSame( $parent->getContentId(), $inherited->getContentId() ); - $this->assertSame( $parent->getAddress(), $inherited->getAddress() ); - $this->assertSame( $parent->getContent(), $inherited->getContent() ); - $this->assertTrue( $inherited->isInherited() ); - $this->assertTrue( $inherited->hasOrigin() ); - $this->assertFalse( $inherited->hasRevision() ); - - // make sure we didn't mess with the internal state of $parent - $this->assertFalse( $parent->isInherited() ); - $this->assertSame( 7, $parent->getRevision() ); - - // This would happen while doing an edit, after saving the revision meta-data - // and content meta-data. - $saved = SlotRecord::newSaved( - 10, - $inherited->getContentId(), - $inherited->getAddress(), - $inherited - ); - $this->assertSame( $parent->getContentId(), $saved->getContentId() ); - $this->assertSame( $parent->getAddress(), $saved->getAddress() ); - $this->assertSame( $parent->getContent(), $saved->getContent() ); - $this->assertTrue( $saved->isInherited() ); - $this->assertTrue( $saved->hasRevision() ); - $this->assertSame( 10, $saved->getRevision() ); - - // make sure we didn't mess with the internal state of $parent or $inherited - $this->assertSame( 7, $parent->getRevision() ); - $this->assertFalse( $inherited->hasRevision() ); - } - - public function testNewSaved() { - // This would happen while doing an edit, before saving revision meta-data. - $unsaved = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); - - // This would happen while doing an edit, after saving the revision meta-data - // and content meta-data. - $saved = SlotRecord::newSaved( 10, 20, 'theNewAddress', $unsaved ); - $this->assertFalse( $saved->isInherited() ); - $this->assertTrue( $saved->hasOrigin() ); - $this->assertTrue( $saved->hasRevision() ); - $this->assertTrue( $saved->hasAddress() ); - $this->assertTrue( $saved->hasContentId() ); - $this->assertSame( 'theNewAddress', $saved->getAddress() ); - $this->assertSame( 20, $saved->getContentId() ); - $this->assertSame( 'A', $saved->getContent()->getText() ); - $this->assertSame( 10, $saved->getRevision() ); - $this->assertSame( 10, $saved->getOrigin() ); - - // make sure we didn't mess with the internal state of $unsaved - $this->assertFalse( $unsaved->hasAddress() ); - $this->assertFalse( $unsaved->hasContentId() ); - $this->assertFalse( $unsaved->hasRevision() ); - } - - public function provideNewSaved_LogicException() { - $freshRow = $this->makeRow( [ - 'content_id' => 10, - 'content_address' => 'address:1', - 'slot_origin' => 1, - 'slot_revision_id' => 1, - ] ); - - $freshSlot = new SlotRecord( $freshRow, new WikitextContent( 'A' ) ); - yield 'mismatching address' => [ 1, 10, 'address:BAD', $freshSlot ]; - yield 'mismatching revision' => [ 5, 10, 'address:1', $freshSlot ]; - yield 'mismatching content ID' => [ 1, 17, 'address:1', $freshSlot ]; - - $inheritedRow = $this->makeRow( [ - 'content_id' => null, - 'content_address' => null, - 'slot_origin' => 0, - 'slot_revision_id' => 1, - ] ); - - $inheritedSlot = new SlotRecord( $inheritedRow, new WikitextContent( 'A' ) ); - yield 'inherited, but no address' => [ 1, 10, 'address:2', $inheritedSlot ]; - } - - /** - * @dataProvider provideNewSaved_LogicException - */ - public function testNewSaved_LogicException( - $revisionId, - $contentId, - $contentAddress, - SlotRecord $protoSlot - ) { - $this->setExpectedException( LogicException::class ); - SlotRecord::newSaved( $revisionId, $contentId, $contentAddress, $protoSlot ); - } - - public function provideNewSaved_InvalidArgumentException() { - $unsaved = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); - - yield 'bad revision id' => [ 'xyzzy', 5, 'address', $unsaved ]; - yield 'bad content id' => [ 7, 'xyzzy', 'address', $unsaved ]; - yield 'bad content address' => [ 7, 5, 77, $unsaved ]; - } - - /** - * @dataProvider provideNewSaved_InvalidArgumentException - */ - public function testNewSaved_InvalidArgumentException( - $revisionId, - $contentId, - $contentAddress, - SlotRecord $protoSlot - ) { - $this->setExpectedException( InvalidArgumentException::class ); - SlotRecord::newSaved( $revisionId, $contentId, $contentAddress, $protoSlot ); - } - - public function provideHasSameContent() { - $fail = function () { - self::fail( 'There should be no need to actually load the content.' ); - }; - - $a100a1 = new SlotRecord( - $this->makeRow( - [ - 'model_name' => 'A', - 'content_size' => 100, - 'content_sha1' => 'hash-a', - 'content_address' => 'xxx:a1', - ] - ), - $fail - ); - $a100a1b = new SlotRecord( - $this->makeRow( - [ - 'model_name' => 'A', - 'content_size' => 100, - 'content_sha1' => 'hash-a', - 'content_address' => 'xxx:a1', - ] - ), - $fail - ); - $a100null = new SlotRecord( - $this->makeRow( - [ - 'model_name' => 'A', - 'content_size' => 100, - 'content_sha1' => 'hash-a', - 'content_address' => null, - ] - ), - $fail - ); - $a100a2 = new SlotRecord( - $this->makeRow( - [ - 'model_name' => 'A', - 'content_size' => 100, - 'content_sha1' => 'hash-a', - 'content_address' => 'xxx:a2', - ] - ), - $fail - ); - $b100a1 = new SlotRecord( - $this->makeRow( - [ - 'model_name' => 'B', - 'content_size' => 100, - 'content_sha1' => 'hash-a', - 'content_address' => 'xxx:a1', - ] - ), - $fail - ); - $a200a1 = new SlotRecord( - $this->makeRow( - [ - 'model_name' => 'A', - 'content_size' => 200, - 'content_sha1' => 'hash-a', - 'content_address' => 'xxx:a2', - ] - ), - $fail - ); - $a100x1 = new SlotRecord( - $this->makeRow( - [ - 'model_name' => 'A', - 'content_size' => 100, - 'content_sha1' => 'hash-x', - 'content_address' => 'xxx:x1', - ] - ), - $fail - ); - - yield 'same instance' => [ $a100a1, $a100a1, true ]; - yield 'no address' => [ $a100a1, $a100null, true ]; - yield 'same address' => [ $a100a1, $a100a1b, true ]; - yield 'different address' => [ $a100a1, $a100a2, true ]; - yield 'different model' => [ $a100a1, $b100a1, false ]; - yield 'different size' => [ $a100a1, $a200a1, false ]; - yield 'different hash' => [ $a100a1, $a100x1, false ]; - } - - /** - * @dataProvider provideHasSameContent - */ - public function testHasSameContent( SlotRecord $a, SlotRecord $b, $sameContent ) { - $this->assertSame( $sameContent, $a->hasSameContent( $b ) ); - $this->assertSame( $sameContent, $b->hasSameContent( $a ) ); - } - -} diff --git a/tests/phpunit/includes/Revision/SlotRoleHandlerTest.php b/tests/phpunit/includes/Revision/SlotRoleHandlerTest.php deleted file mode 100644 index 67e9464f33..0000000000 --- a/tests/phpunit/includes/Revision/SlotRoleHandlerTest.php +++ /dev/null @@ -1,67 +0,0 @@ -getMockBuilder( Title::class ) - ->disableOriginalConstructor() - ->getMock(); - - return $title; - } - - /** - * @covers \MediaWiki\Revision\SlotRoleHandler::__construct - * @covers \MediaWiki\Revision\SlotRoleHandler::getRole() - * @covers \MediaWiki\Revision\SlotRoleHandler::getNameMessageKey() - * @covers \MediaWiki\Revision\SlotRoleHandler::getDefaultModel() - * @covers \MediaWiki\Revision\SlotRoleHandler::getOutputLayoutHints() - */ - public function testConstruction() { - $handler = new SlotRoleHandler( 'foo', 'FooModel', [ 'frob' => 'niz' ] ); - $this->assertSame( 'foo', $handler->getRole() ); - $this->assertSame( 'slot-name-foo', $handler->getNameMessageKey() ); - - $title = $this->makeBlankTitleObject(); - $this->assertSame( 'FooModel', $handler->getDefaultModel( $title ) ); - - $hints = $handler->getOutputLayoutHints(); - $this->assertArrayHasKey( 'frob', $hints ); - $this->assertSame( 'niz', $hints['frob'] ); - - $this->assertArrayHasKey( 'display', $hints ); - $this->assertArrayHasKey( 'region', $hints ); - $this->assertArrayHasKey( 'placement', $hints ); - } - - /** - * @covers \MediaWiki\Revision\SlotRoleHandler::isAllowedModel() - */ - public function testIsAllowedModel() { - $handler = new SlotRoleHandler( 'foo', 'FooModel' ); - - $title = $this->makeBlankTitleObject(); - $this->assertTrue( $handler->isAllowedModel( 'FooModel', $title ) ); - $this->assertFalse( $handler->isAllowedModel( 'QuaxModel', $title ) ); - } - - /** - * @covers \MediaWiki\Revision\SlotRoleHandler::supportsArticleCount() - */ - public function testSupportsArticleCount() { - $handler = new SlotRoleHandler( 'foo', 'FooModel' ); - - $this->assertFalse( $handler->supportsArticleCount() ); - } - -} diff --git a/tests/phpunit/includes/Revision/SlotRoleRegistryTest.php b/tests/phpunit/includes/Revision/SlotRoleRegistryTest.php index 4d8030d519..c48a33a8af 100644 --- a/tests/phpunit/includes/Revision/SlotRoleRegistryTest.php +++ b/tests/phpunit/includes/Revision/SlotRoleRegistryTest.php @@ -17,13 +17,11 @@ use Wikimedia\Assert\PostconditionException; */ class SlotRoleRegistryTest extends MediaWikiTestCase { + /** + * @return Title + */ private function makeBlankTitleObject() { - /** @var Title $title */ - $title = $this->getMockBuilder( Title::class ) - ->disableOriginalConstructor() - ->getMock(); - - return $title; + return $this->createMock( Title::class ); } private function makeNameTableStore( array $names = [] ) { diff --git a/tests/phpunit/includes/RevisionDbTestBase.php b/tests/phpunit/includes/RevisionDbTestBase.php index bbd034a30d..83e8d8506f 100644 --- a/tests/phpunit/includes/RevisionDbTestBase.php +++ b/tests/phpunit/includes/RevisionDbTestBase.php @@ -1468,7 +1468,7 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { $rev->getPage(), $rev->getId() ); - $cache->delete( $key, WANObjectCache::HOLDOFF_NONE ); + $cache->delete( $key, WANObjectCache::HOLDOFF_TTL_NONE ); $this->assertFalse( $cache->get( $key ) ); ++$now; @@ -1539,8 +1539,7 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { public function testUserCanBitfield( $bitField, $field, $userGroups, $title, $expected ) { $title = Title::newFromText( $title ); - $this->setMwGlobals( - 'wgGroupPermissions', + $this->setGroupPermissions( [ 'sysop' => [ 'deletedtext' => true, @@ -1592,8 +1591,7 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { * @covers Revision::userCan */ public function testUserCan( $bitField, $field, $userGroups, $expected ) { - $this->setMwGlobals( - 'wgGroupPermissions', + $this->setGroupPermissions( [ 'sysop' => [ 'deletedtext' => true, diff --git a/tests/phpunit/includes/RevisionTest.php b/tests/phpunit/includes/RevisionTest.php index 98f2980f93..d62e4c7402 100644 --- a/tests/phpunit/includes/RevisionTest.php +++ b/tests/phpunit/includes/RevisionTest.php @@ -438,10 +438,11 @@ class RevisionTest extends MediaWikiTestCase { $lb = $this->getMockBuilder( LoadBalancer::class ) ->disableOriginalConstructor() ->getMock(); - + $access = MediaWikiServices::getInstance()->getExternalStoreAccess(); $cache = $this->getWANObjectCache(); - $blobStore = new SqlBlobStore( $lb, $cache ); + $blobStore = new SqlBlobStore( $lb, $access, $cache ); + return $blobStore; } @@ -807,7 +808,7 @@ class RevisionTest extends MediaWikiTestCase { public function testGetRevisionText_external_noOldId() { $this->setService( 'ExternalStoreFactory', - new ExternalStoreFactory( [ 'ForTesting' ] ) + new ExternalStoreFactory( [ 'ForTesting' ], [ 'ForTesting://cluster1' ], 'test-id' ) ); $this->assertSame( 'AAAABBAAA', @@ -829,14 +830,15 @@ class RevisionTest extends MediaWikiTestCase { $this->setService( 'ExternalStoreFactory', - new ExternalStoreFactory( [ 'ForTesting' ] ) + new ExternalStoreFactory( [ 'ForTesting' ], [ 'ForTesting://cluster1' ], 'test-id' ) ); $lb = $this->getMockBuilder( LoadBalancer::class ) ->disableOriginalConstructor() ->getMock(); + $access = MediaWikiServices::getInstance()->getExternalStoreAccess(); - $blobStore = new SqlBlobStore( $lb, $cache ); + $blobStore = new SqlBlobStore( $lb, $access, $cache ); $this->setService( 'BlobStoreFactory', $this->mockBlobStoreFactory( $blobStore ) ); $this->assertSame( diff --git a/tests/phpunit/includes/SampleTest.php b/tests/phpunit/includes/SampleTest.php index da6df70e69..2d8579fa96 100644 --- a/tests/phpunit/includes/SampleTest.php +++ b/tests/phpunit/includes/SampleTest.php @@ -31,10 +31,8 @@ class SampleTest extends MediaWikiLangTestCase { /** * Name tests so that PHPUnit can turn them into sentences when - * they run. While MediaWiki isn't strictly an Agile Programming - * project, you are encouraged to use the naming described under - * "Agile Documentation" at - * https://www.phpunit.de/manual/3.4/en/other-uses-for-tests.html + * they run. You are encouraged to use the naming described at: + * https://phpunit.de/manual/6.5/en/other-uses-for-tests.html */ public function testTitleObjectStringConversion() { $title = Title::newFromText( "text" ); @@ -47,7 +45,7 @@ class SampleTest extends MediaWikiLangTestCase { /** * If you want to run the same test with a variety of data, use a data provider. - * see: https://www.phpunit.de/manual/3.4/en/writing-tests-for-phpunit.html + * See https://phpunit.de/manual/6.5/en/writing-tests-for-phpunit.html */ public static function provideTitles() { return [ @@ -62,7 +60,7 @@ class SampleTest extends MediaWikiLangTestCase { /** * phpcs:disable Generic.Files.LineLength * @dataProvider provideTitles - * See https://phpunit.de/manual/3.7/en/appendixes.annotations.html#appendixes.annotations.dataProvider + * See https://phpunit.de/manual/6.5/en/appendixes.annotations.html#appendixes.annotations.dataProvider * phpcs:enable */ public function testCreateBasicListOfTitles( $titleName, $ns, $text ) { @@ -91,7 +89,7 @@ class SampleTest extends MediaWikiLangTestCase { /** * @depends testSetUpMainPageTitleForNextTest - * See https://phpunit.de/manual/3.7/en/appendixes.annotations.html#appendixes.annotations.depends + * See https://phpunit.de/manual/6.5/en/appendixes.annotations.html#appendixes.annotations.depends */ public function testCheckMainPageTitleIsConsideredLocal( $title ) { $this->assertTrue( $title->isLocal() ); @@ -99,7 +97,7 @@ class SampleTest extends MediaWikiLangTestCase { /** * @expectedException InvalidArgumentException - * See https://phpunit.de/manual/3.7/en/appendixes.annotations.html#appendixes.annotations.expectedException + * See https://phpunit.de/manual/6.5/en/appendixes.annotations.html#appendixes.annotations.expectedException */ public function testTitleObjectFromObject() { $title = Title::newFromText( Title::newFromText( "test" ) ); diff --git a/tests/phpunit/includes/SanitizerValidateEmailTest.php b/tests/phpunit/includes/SanitizerValidateEmailTest.php deleted file mode 100644 index c4e430848b..0000000000 --- a/tests/phpunit/includes/SanitizerValidateEmailTest.php +++ /dev/null @@ -1,105 +0,0 @@ -assertEquals( - $expected, - Sanitizer::validateEmail( $addr ), - $msg - ); - } - - private function valid( $addr, $msg = '' ) { - $this->checkEmail( $addr, true, $msg ); - } - - private function invalid( $addr, $msg = '' ) { - $this->checkEmail( $addr, false, $msg ); - } - - public function testEmailWellKnownUserAtHostDotTldAreValid() { - $this->valid( 'user@example.com' ); - $this->valid( 'user@example.museum' ); - } - - public function testEmailWithUpperCaseCharactersAreValid() { - $this->valid( 'USER@example.com' ); - $this->valid( 'user@EXAMPLE.COM' ); - $this->valid( 'user@Example.com' ); - $this->valid( 'USER@eXAMPLE.com' ); - } - - public function testEmailWithAPlusInUserName() { - $this->valid( 'user+sub@example.com' ); - $this->valid( 'user+@example.com' ); - } - - public function testEmailDoesNotNeedATopLevelDomain() { - $this->valid( "user@localhost" ); - $this->valid( "FooBar@localdomain" ); - $this->valid( "nobody@mycompany" ); - } - - public function testEmailWithWhiteSpacesBeforeOrAfterAreInvalids() { - $this->invalid( " user@host.com" ); - $this->invalid( "user@host.com " ); - $this->invalid( "\tuser@host.com" ); - $this->invalid( "user@host.com\t" ); - } - - public function testEmailWithWhiteSpacesAreInvalids() { - $this->invalid( "User user@host" ); - $this->invalid( "first last@mycompany" ); - $this->invalid( "firstlast@my company" ); - } - - /** - * T28948 : comma were matched by an incorrect regexp range - */ - public function testEmailWithCommasAreInvalids() { - $this->invalid( "user,foo@example.org" ); - $this->invalid( "userfoo@ex,ample.org" ); - } - - public function testEmailWithHyphens() { - $this->valid( "user-foo@example.org" ); - $this->valid( "userfoo@ex-ample.org" ); - } - - public function testEmailDomainCanNotBeginWithDot() { - $this->invalid( "user@." ); - $this->invalid( "user@.localdomain" ); - $this->invalid( "user@localdomain." ); - $this->valid( "user.@localdomain" ); - $this->valid( ".@localdomain" ); - $this->invalid( ".@a............" ); - } - - public function testEmailWithFunnyCharacters() { - $this->valid( "\$user!ex{this}@123.com" ); - } - - public function testEmailTopLevelDomainCanBeNumerical() { - $this->valid( "user@example.1234" ); - } - - public function testEmailWithoutAtSignIsInvalid() { - $this->invalid( 'useràexample.com' ); - } - - public function testEmailWithOneCharacterDomainIsValid() { - $this->valid( 'user@a' ); - } -} diff --git a/tests/phpunit/includes/ServiceWiringTest.php b/tests/phpunit/includes/ServiceWiringTest.php deleted file mode 100644 index 02e06f8dda..0000000000 --- a/tests/phpunit/includes/ServiceWiringTest.php +++ /dev/null @@ -1,16 +0,0 @@ -assertSame( $sortedServices, $services, - 'Please keep services sorted alphabetically' ); - } -} diff --git a/tests/phpunit/includes/SiteConfigurationTest.php b/tests/phpunit/includes/SiteConfigurationTest.php deleted file mode 100644 index 3b7226245f..0000000000 --- a/tests/phpunit/includes/SiteConfigurationTest.php +++ /dev/null @@ -1,379 +0,0 @@ -mConf = new SiteConfiguration; - - $this->mConf->suffixes = [ 'wikipedia' => 'wiki' ]; - $this->mConf->wikis = [ 'enwiki', 'dewiki', 'frwiki' ]; - $this->mConf->settings = [ - 'SimpleKey' => [ - 'wiki' => 'wiki', - 'tag' => 'tag', - 'enwiki' => 'enwiki', - 'dewiki' => 'dewiki', - 'frwiki' => 'frwiki', - ], - - 'Fallback' => [ - 'default' => 'default', - 'wiki' => 'wiki', - 'tag' => 'tag', - 'frwiki' => 'frwiki', - 'null_wiki' => null, - ], - - 'WithParams' => [ - 'default' => '$lang $site $wiki', - ], - - '+SomeGlobal' => [ - 'wiki' => [ - 'wiki' => 'wiki', - ], - 'tag' => [ - 'tag' => 'tag', - ], - 'enwiki' => [ - 'enwiki' => 'enwiki', - ], - 'dewiki' => [ - 'dewiki' => 'dewiki', - ], - 'frwiki' => [ - 'frwiki' => 'frwiki', - ], - ], - - 'MergeIt' => [ - '+wiki' => [ - 'wiki' => 'wiki', - ], - '+tag' => [ - 'tag' => 'tag', - ], - 'default' => [ - 'default' => 'default', - ], - '+enwiki' => [ - 'enwiki' => 'enwiki', - ], - '+dewiki' => [ - 'dewiki' => 'dewiki', - ], - '+frwiki' => [ - 'frwiki' => 'frwiki', - ], - ], - ]; - - $GLOBALS['SomeGlobal'] = [ 'SomeGlobal' => 'SomeGlobal' ]; - } - - /** - * This function is used as a callback within the tests below - */ - public static function getSiteParamsCallback( $conf, $wiki ) { - $site = null; - $lang = null; - foreach ( $conf->suffixes as $suffix ) { - if ( substr( $wiki, -strlen( $suffix ) ) == $suffix ) { - $site = $suffix; - $lang = substr( $wiki, 0, -strlen( $suffix ) ); - break; - } - } - - return [ - 'suffix' => $site, - 'lang' => $lang, - 'params' => [ - 'lang' => $lang, - 'site' => $site, - 'wiki' => $wiki, - ], - 'tags' => [ 'tag' ], - ]; - } - - /** - * @covers SiteConfiguration::siteFromDB - */ - public function testSiteFromDb() { - $this->assertEquals( - [ 'wikipedia', 'en' ], - $this->mConf->siteFromDB( 'enwiki' ), - 'siteFromDB()' - ); - $this->assertEquals( - [ 'wikipedia', '' ], - $this->mConf->siteFromDB( 'wiki' ), - 'siteFromDB() on a suffix' - ); - $this->assertEquals( - [ null, null ], - $this->mConf->siteFromDB( 'wikien' ), - 'siteFromDB() on a non-existing wiki' - ); - - $this->mConf->suffixes = [ 'wiki', '' ]; - $this->assertEquals( - [ '', 'wikien' ], - $this->mConf->siteFromDB( 'wikien' ), - 'siteFromDB() on a non-existing wiki (2)' - ); - } - - /** - * @covers SiteConfiguration::getLocalDatabases - */ - public function testGetLocalDatabases() { - $this->assertEquals( - [ 'enwiki', 'dewiki', 'frwiki' ], - $this->mConf->getLocalDatabases(), - 'getLocalDatabases()' - ); - } - - /** - * @covers SiteConfiguration::get - */ - public function testGetConfVariables() { - // Simple - $this->assertEquals( - 'enwiki', - $this->mConf->get( 'SimpleKey', 'enwiki', 'wiki' ), - 'get(): simple setting on an existing wiki' - ); - $this->assertEquals( - 'dewiki', - $this->mConf->get( 'SimpleKey', 'dewiki', 'wiki' ), - 'get(): simple setting on an existing wiki (2)' - ); - $this->assertEquals( - 'frwiki', - $this->mConf->get( 'SimpleKey', 'frwiki', 'wiki' ), - 'get(): simple setting on an existing wiki (3)' - ); - $this->assertEquals( - 'wiki', - $this->mConf->get( 'SimpleKey', 'wiki', 'wiki' ), - 'get(): simple setting on an suffix' - ); - $this->assertEquals( - 'wiki', - $this->mConf->get( 'SimpleKey', 'eswiki', 'wiki' ), - 'get(): simple setting on an non-existing wiki' - ); - - // Fallback - $this->assertEquals( - 'wiki', - $this->mConf->get( 'Fallback', 'enwiki', 'wiki' ), - 'get(): fallback setting on an existing wiki' - ); - $this->assertEquals( - 'tag', - $this->mConf->get( 'Fallback', 'dewiki', 'wiki', [], [ 'tag' ] ), - 'get(): fallback setting on an existing wiki (with wiki tag)' - ); - $this->assertEquals( - 'frwiki', - $this->mConf->get( 'Fallback', 'frwiki', 'wiki', [], [ 'tag' ] ), - 'get(): no fallback if wiki has its own setting (matching tag)' - ); - $this->assertSame( - // Potential regression test for T192855 - null, - $this->mConf->get( 'Fallback', 'null_wiki', 'wiki', [], [ 'tag' ] ), - 'get(): no fallback if wiki has its own setting (matching tag and uses null)' - ); - $this->assertEquals( - 'wiki', - $this->mConf->get( 'Fallback', 'wiki', 'wiki' ), - 'get(): fallback setting on an suffix' - ); - $this->assertEquals( - 'wiki', - $this->mConf->get( 'Fallback', 'wiki', 'wiki', [], [ 'tag' ] ), - 'get(): fallback setting on an suffix (with wiki tag)' - ); - $this->assertEquals( - 'wiki', - $this->mConf->get( 'Fallback', 'eswiki', 'wiki' ), - 'get(): fallback setting on an non-existing wiki' - ); - $this->assertEquals( - 'tag', - $this->mConf->get( 'Fallback', 'eswiki', 'wiki', [], [ 'tag' ] ), - 'get(): fallback setting on an non-existing wiki (with wiki tag)' - ); - - // Merging - $common = [ 'wiki' => 'wiki', 'default' => 'default' ]; - $commonTag = [ 'tag' => 'tag', 'wiki' => 'wiki', 'default' => 'default' ]; - $this->assertEquals( - [ 'enwiki' => 'enwiki' ] + $common, - $this->mConf->get( 'MergeIt', 'enwiki', 'wiki' ), - 'get(): merging setting on an existing wiki' - ); - $this->assertEquals( - [ 'enwiki' => 'enwiki' ] + $commonTag, - $this->mConf->get( 'MergeIt', 'enwiki', 'wiki', [], [ 'tag' ] ), - 'get(): merging setting on an existing wiki (with tag)' - ); - $this->assertEquals( - [ 'dewiki' => 'dewiki' ] + $common, - $this->mConf->get( 'MergeIt', 'dewiki', 'wiki' ), - 'get(): merging setting on an existing wiki (2)' - ); - $this->assertEquals( - [ 'dewiki' => 'dewiki' ] + $commonTag, - $this->mConf->get( 'MergeIt', 'dewiki', 'wiki', [], [ 'tag' ] ), - 'get(): merging setting on an existing wiki (2) (with tag)' - ); - $this->assertEquals( - [ 'frwiki' => 'frwiki' ] + $common, - $this->mConf->get( 'MergeIt', 'frwiki', 'wiki' ), - 'get(): merging setting on an existing wiki (3)' - ); - $this->assertEquals( - [ 'frwiki' => 'frwiki' ] + $commonTag, - $this->mConf->get( 'MergeIt', 'frwiki', 'wiki', [], [ 'tag' ] ), - 'get(): merging setting on an existing wiki (3) (with tag)' - ); - $this->assertEquals( - [ 'wiki' => 'wiki' ] + $common, - $this->mConf->get( 'MergeIt', 'wiki', 'wiki' ), - 'get(): merging setting on an suffix' - ); - $this->assertEquals( - [ 'wiki' => 'wiki' ] + $commonTag, - $this->mConf->get( 'MergeIt', 'wiki', 'wiki', [], [ 'tag' ] ), - 'get(): merging setting on an suffix (with tag)' - ); - $this->assertEquals( - $common, - $this->mConf->get( 'MergeIt', 'eswiki', 'wiki' ), - 'get(): merging setting on an non-existing wiki' - ); - $this->assertEquals( - $commonTag, - $this->mConf->get( 'MergeIt', 'eswiki', 'wiki', [], [ 'tag' ] ), - 'get(): merging setting on an non-existing wiki (with tag)' - ); - } - - /** - * @covers SiteConfiguration::siteFromDB - */ - public function testSiteFromDbWithCallback() { - $this->mConf->siteParamsCallback = 'SiteConfigurationTest::getSiteParamsCallback'; - - $this->assertEquals( - [ 'wiki', 'en' ], - $this->mConf->siteFromDB( 'enwiki' ), - 'siteFromDB() with callback' - ); - $this->assertEquals( - [ 'wiki', '' ], - $this->mConf->siteFromDB( 'wiki' ), - 'siteFromDB() with callback on a suffix' - ); - $this->assertEquals( - [ null, null ], - $this->mConf->siteFromDB( 'wikien' ), - 'siteFromDB() with callback on a non-existing wiki' - ); - } - - /** - * @covers SiteConfiguration::get - */ - public function testParameterReplacement() { - $this->mConf->siteParamsCallback = 'SiteConfigurationTest::getSiteParamsCallback'; - - $this->assertEquals( - 'en wiki enwiki', - $this->mConf->get( 'WithParams', 'enwiki', 'wiki' ), - 'get(): parameter replacement on an existing wiki' - ); - $this->assertEquals( - 'de wiki dewiki', - $this->mConf->get( 'WithParams', 'dewiki', 'wiki' ), - 'get(): parameter replacement on an existing wiki (2)' - ); - $this->assertEquals( - 'fr wiki frwiki', - $this->mConf->get( 'WithParams', 'frwiki', 'wiki' ), - 'get(): parameter replacement on an existing wiki (3)' - ); - $this->assertEquals( - ' wiki wiki', - $this->mConf->get( 'WithParams', 'wiki', 'wiki' ), - 'get(): parameter replacement on an suffix' - ); - $this->assertEquals( - 'es wiki eswiki', - $this->mConf->get( 'WithParams', 'eswiki', 'wiki' ), - 'get(): parameter replacement on an non-existing wiki' - ); - } - - /** - * @covers SiteConfiguration::getAll - */ - public function testGetAllGlobals() { - $this->mConf->siteParamsCallback = 'SiteConfigurationTest::getSiteParamsCallback'; - - $getall = [ - 'SimpleKey' => 'enwiki', - 'Fallback' => 'tag', - 'WithParams' => 'en wiki enwiki', - 'SomeGlobal' => [ 'enwiki' => 'enwiki' ] + $GLOBALS['SomeGlobal'], - 'MergeIt' => [ - 'enwiki' => 'enwiki', - 'tag' => 'tag', - 'wiki' => 'wiki', - 'default' => 'default' - ], - ]; - $this->assertEquals( $getall, $this->mConf->getAll( 'enwiki' ), 'getAll()' ); - - $this->mConf->extractAllGlobals( 'enwiki', 'wiki' ); - - $this->assertEquals( - $getall['SimpleKey'], - $GLOBALS['SimpleKey'], - 'extractAllGlobals(): simple setting' - ); - $this->assertEquals( - $getall['Fallback'], - $GLOBALS['Fallback'], - 'extractAllGlobals(): fallback setting' - ); - $this->assertEquals( - $getall['WithParams'], - $GLOBALS['WithParams'], - 'extractAllGlobals(): parameter replacement' - ); - $this->assertEquals( - $getall['SomeGlobal'], - $GLOBALS['SomeGlobal'], - 'extractAllGlobals(): merging with global' - ); - $this->assertEquals( - $getall['MergeIt'], - $GLOBALS['MergeIt'], - 'extractAllGlobals(): merging setting' - ); - } -} diff --git a/tests/phpunit/includes/StatusTest.php b/tests/phpunit/includes/StatusTest.php index 6e62afdd4d..37ebf4cfcb 100644 --- a/tests/phpunit/includes/StatusTest.php +++ b/tests/phpunit/includes/StatusTest.php @@ -237,7 +237,7 @@ class StatusTest extends MediaWikiLangTestCase { } /** - * @param array $messageDetails E.g. array( 'KEY' => array(/PARAMS/) ) + * @param array $messageDetails E.g. [ 'KEY' => [ /PARAMS/ ] ] * @return Message[] */ protected function getMockMessages( $messageDetails ) { diff --git a/tests/phpunit/includes/Storage/BlobStoreFactoryTest.php b/tests/phpunit/includes/Storage/BlobStoreFactoryTest.php index 252c657867..0249730f1d 100644 --- a/tests/phpunit/includes/Storage/BlobStoreFactoryTest.php +++ b/tests/phpunit/includes/Storage/BlobStoreFactoryTest.php @@ -13,34 +13,34 @@ use Wikimedia\TestingAccessWrapper; */ class BlobStoreFactoryTest extends MediaWikiTestCase { - public function provideWikiIds() { + public function provideDbDomains() { yield [ false ]; yield [ 'someWiki' ]; } /** - * @dataProvider provideWikiIds + * @dataProvider provideDbDomains */ - public function testNewBlobStore( $wikiId ) { + public function testNewBlobStore( $dbDomain ) { $factory = MediaWikiServices::getInstance()->getBlobStoreFactory(); - $store = $factory->newBlobStore( $wikiId ); + $store = $factory->newBlobStore( $dbDomain ); $this->assertInstanceOf( BlobStore::class, $store ); // This only works as we currently know this is a SqlBlobStore object $wrapper = TestingAccessWrapper::newFromObject( $store ); - $this->assertEquals( $wikiId, $wrapper->wikiId ); + $this->assertEquals( $dbDomain, $wrapper->dbDomain ); } /** - * @dataProvider provideWikiIds + * @dataProvider provideDbDomains */ - public function testNewSqlBlobStore( $wikiId ) { + public function testNewSqlBlobStore( $dbDomain ) { $factory = MediaWikiServices::getInstance()->getBlobStoreFactory(); - $store = $factory->newSqlBlobStore( $wikiId ); + $store = $factory->newSqlBlobStore( $dbDomain ); $this->assertInstanceOf( SqlBlobStore::class, $store ); $wrapper = TestingAccessWrapper::newFromObject( $store ); - $this->assertEquals( $wikiId, $wrapper->wikiId ); + $this->assertEquals( $dbDomain, $wrapper->dbDomain ); } } diff --git a/tests/phpunit/includes/Storage/NameTableStoreTest.php b/tests/phpunit/includes/Storage/NameTableStoreTest.php index ca87b49a37..47d3b92747 100644 --- a/tests/phpunit/includes/Storage/NameTableStoreTest.php +++ b/tests/phpunit/includes/Storage/NameTableStoreTest.php @@ -10,7 +10,7 @@ use MediaWiki\Storage\NameTableStore; use MediaWikiTestCase; use Psr\Log\NullLogger; use WANObjectCache; -use Wikimedia\Rdbms\Database; +use Wikimedia\Rdbms\IDatabase; use Wikimedia\Rdbms\LoadBalancer; use Wikimedia\TestingAccessWrapper; @@ -57,37 +57,25 @@ class NameTableStoreTest extends MediaWikiTestCase { } private function getCallCheckingDb( $insertCalls, $selectCalls ) { - $mock = $this->getMockBuilder( Database::class ) + $proxiedMethods = [ + 'select' => $selectCalls, + 'insert' => $insertCalls, + 'affectedRows' => null, + 'insertId' => null, + 'getSessionLagStatus' => null, + 'writesPending' => null, + 'onTransactionPreCommitOrIdle' => null + ]; + $mock = $this->getMockBuilder( IDatabase::class ) ->disableOriginalConstructor() ->getMock(); - $mock->expects( $this->exactly( $insertCalls ) ) - ->method( 'insert' ) - ->willReturnCallback( function ( ...$args ) { - return call_user_func_array( [ $this->db, 'insert' ], $args ); - } ); - $mock->expects( $this->exactly( $selectCalls ) ) - ->method( 'select' ) - ->willReturnCallback( function ( ...$args ) { - return call_user_func_array( [ $this->db, 'select' ], $args ); - } ); - $mock->expects( $this->exactly( $insertCalls ) ) - ->method( 'affectedRows' ) - ->willReturnCallback( function ( ...$args ) { - return call_user_func_array( [ $this->db, 'affectedRows' ], $args ); - } ); - $mock->expects( $this->any() ) - ->method( 'insertId' ) - ->willReturnCallback( function ( ...$args ) { - return call_user_func_array( [ $this->db, 'insertId' ], $args ); - } ); - $mock->expects( $this->any() ) - ->method( 'query' ) - ->willReturn( [] ); - $mock->expects( $this->any() ) - ->method( 'isOpen' ) - ->willReturn( true ); - $wrapper = TestingAccessWrapper::newFromObject( $mock ); - $wrapper->queryLogger = new NullLogger(); + foreach ( $proxiedMethods as $method => $count ) { + $mock->expects( is_int( $count ) ? $this->exactly( $count ) : $this->any() ) + ->method( $method ) + ->willReturnCallback( function ( ...$args ) use ( $method ) { + return call_user_func_array( [ $this->db, $method ], $args ); + } ); + } return $mock; } diff --git a/tests/phpunit/includes/Storage/PreparedEditTest.php b/tests/phpunit/includes/Storage/PreparedEditTest.php deleted file mode 100644 index 29999ee535..0000000000 --- a/tests/phpunit/includes/Storage/PreparedEditTest.php +++ /dev/null @@ -1,22 +0,0 @@ -parserOutputCallback = function () { - return new ParserOutput(); - }; - - $this->assertEquals( $output, $edit->getOutput() ); - $this->assertEquals( $output, $edit->output ); - } -} diff --git a/tests/phpunit/includes/Storage/SqlBlobStoreTest.php b/tests/phpunit/includes/Storage/SqlBlobStoreTest.php index 55069403d6..ac39b48cb9 100644 --- a/tests/phpunit/includes/Storage/SqlBlobStoreTest.php +++ b/tests/phpunit/includes/Storage/SqlBlobStoreTest.php @@ -24,6 +24,7 @@ class SqlBlobStoreTest extends MediaWikiTestCase { $store = new SqlBlobStore( $services->getDBLoadBalancer(), + $services->getExternalStoreAccess(), $services->getMainWANObjectCache() ); diff --git a/tests/phpunit/includes/TemplateCategoriesTest.php b/tests/phpunit/includes/TemplateCategoriesTest.php index ebd8dbd3da..04addab4a6 100644 --- a/tests/phpunit/includes/TemplateCategoriesTest.php +++ b/tests/phpunit/includes/TemplateCategoriesTest.php @@ -15,7 +15,7 @@ class TemplateCategoriesTest extends MediaWikiLangTestCase { */ public function testTemplateCategories() { $user = new User(); - $user->mRights = [ 'createpage', 'edit', 'purge', 'delete' ]; + $this->overrideUserPermissions( $user, [ 'createpage', 'edit', 'purge', 'delete' ] ); $title = Title::newFromText( "Categorized from template" ); $page = WikiPage::factory( $title ); diff --git a/tests/phpunit/includes/TestLogger.php b/tests/phpunit/includes/TestLogger.php index e50e1bcfd0..fd45732422 100644 --- a/tests/phpunit/includes/TestLogger.php +++ b/tests/phpunit/includes/TestLogger.php @@ -73,8 +73,8 @@ class TestLogger extends \Psr\Log\AbstractLogger { /** * Return the collected logs - * @return array Array of array( string $level, string $message ), or - * array( string $level, string $message, array $context ) if $collectContext was true. + * @return array Array of [ string $level, string $message ], or + * [ string $level, string $message, array $context ] if $collectContext was true. */ public function getBuffer() { return $this->buffer; diff --git a/tests/phpunit/includes/TitleArrayFromResultTest.php b/tests/phpunit/includes/TitleArrayFromResultTest.php deleted file mode 100644 index af49ecf73a..0000000000 --- a/tests/phpunit/includes/TitleArrayFromResultTest.php +++ /dev/null @@ -1,121 +0,0 @@ -getMockBuilder( Wikimedia\Rdbms\ResultWrapper::class ) - ->disableOriginalConstructor(); - - $resultWrapper = $resultWrapper->getMock(); - $resultWrapper->expects( $this->atLeastOnce() ) - ->method( 'current' ) - ->will( $this->returnValue( $row ) ); - $resultWrapper->expects( $this->any() ) - ->method( 'numRows' ) - ->will( $this->returnValue( $numRows ) ); - - return $resultWrapper; - } - - private function getRowWithTitle( $namespace = 3, $title = 'foo' ) { - $row = new stdClass(); - $row->page_namespace = $namespace; - $row->page_title = $title; - return $row; - } - - private function getTitleArrayFromResult( $resultWrapper ) { - return new TitleArrayFromResult( $resultWrapper ); - } - - /** - * @covers TitleArrayFromResult::__construct - */ - public function testConstructionWithFalseRow() { - $row = false; - $resultWrapper = $this->getMockResultWrapper( $row ); - - $object = $this->getTitleArrayFromResult( $resultWrapper ); - - $this->assertEquals( $resultWrapper, $object->res ); - $this->assertSame( 0, $object->key ); - $this->assertEquals( $row, $object->current ); - } - - /** - * @covers TitleArrayFromResult::__construct - */ - public function testConstructionWithRow() { - $namespace = 0; - $title = 'foo'; - $row = $this->getRowWithTitle( $namespace, $title ); - $resultWrapper = $this->getMockResultWrapper( $row ); - - $object = $this->getTitleArrayFromResult( $resultWrapper ); - - $this->assertEquals( $resultWrapper, $object->res ); - $this->assertSame( 0, $object->key ); - $this->assertInstanceOf( Title::class, $object->current ); - $this->assertEquals( $namespace, $object->current->mNamespace ); - $this->assertEquals( $title, $object->current->mTextform ); - } - - public static function provideNumberOfRows() { - return [ - [ 0 ], - [ 1 ], - [ 122 ], - ]; - } - - /** - * @dataProvider provideNumberOfRows - * @covers TitleArrayFromResult::count - */ - public function testCountWithVaryingValues( $numRows ) { - $object = $this->getTitleArrayFromResult( $this->getMockResultWrapper( - $this->getRowWithTitle(), - $numRows - ) ); - $this->assertEquals( $numRows, $object->count() ); - } - - /** - * @covers TitleArrayFromResult::current - */ - public function testCurrentAfterConstruction() { - $namespace = 0; - $title = 'foo'; - $row = $this->getRowWithTitle( $namespace, $title ); - $object = $this->getTitleArrayFromResult( $this->getMockResultWrapper( $row ) ); - $this->assertInstanceOf( Title::class, $object->current() ); - $this->assertEquals( $namespace, $object->current->mNamespace ); - $this->assertEquals( $title, $object->current->mTextform ); - } - - public function provideTestValid() { - return [ - [ $this->getRowWithTitle(), true ], - [ false, false ], - ]; - } - - /** - * @dataProvider provideTestValid - * @covers TitleArrayFromResult::valid - */ - public function testValid( $input, $expected ) { - $object = $this->getTitleArrayFromResult( $this->getMockResultWrapper( $input ) ); - $this->assertEquals( $expected, $object->valid() ); - } - - // @todo unit test for key() - // @todo unit test for next() - // @todo unit test for rewind() -} diff --git a/tests/phpunit/includes/TitlePermissionTest.php b/tests/phpunit/includes/TitlePermissionTest.php index e09546e7ee..e6cf8c8b89 100644 --- a/tests/phpunit/includes/TitlePermissionTest.php +++ b/tests/phpunit/includes/TitlePermissionTest.php @@ -72,17 +72,7 @@ class TitlePermissionTest extends MediaWikiLangTestCase { $this->user = $this->userUser; } - $this->overrideMwServices(); - } - - protected function setUserPerm( $perm ) { - // Setting member variables is evil!!! - - if ( is_array( $perm ) ) { - $this->user->mRights = $perm; - } else { - $this->user->mRights = [ $perm ]; - } + $this->resetServices(); } protected function setTitle( $ns, $title = "Main_Page" ) { @@ -114,139 +104,139 @@ class TitlePermissionTest extends MediaWikiLangTestCase { $this->setUser( 'anon' ); $this->setTitle( NS_TALK ); - $this->setUserPerm( "createtalk" ); + $this->overrideUserPermissions( $this->user, "createtalk" ); $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); $this->assertEquals( [], $res ); $this->setTitle( NS_TALK ); - $this->setUserPerm( "createpage" ); + $this->overrideUserPermissions( $this->user, "createpage" ); $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); $this->assertEquals( [ [ "nocreatetext" ] ], $res ); $this->setTitle( NS_TALK ); - $this->setUserPerm( "" ); + $this->overrideUserPermissions( $this->user, "" ); $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); $this->assertEquals( [ [ 'nocreatetext' ] ], $res ); $this->setTitle( NS_MAIN ); - $this->setUserPerm( "createpage" ); + $this->overrideUserPermissions( $this->user, "createpage" ); $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); $this->assertEquals( [], $res ); $this->setTitle( NS_MAIN ); - $this->setUserPerm( "createtalk" ); + $this->overrideUserPermissions( $this->user, "createtalk" ); $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); $this->assertEquals( [ [ 'nocreatetext' ] ], $res ); $this->setUser( $this->userName ); $this->setTitle( NS_TALK ); - $this->setUserPerm( "createtalk" ); + $this->overrideUserPermissions( $this->user, "createtalk" ); $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); $this->assertEquals( [], $res ); $this->setTitle( NS_TALK ); - $this->setUserPerm( "createpage" ); + $this->overrideUserPermissions( $this->user, "createpage" ); $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); $this->assertEquals( [ [ 'nocreate-loggedin' ] ], $res ); $this->setTitle( NS_TALK ); - $this->setUserPerm( "" ); + $this->overrideUserPermissions( $this->user ); $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); $this->assertEquals( [ [ 'nocreate-loggedin' ] ], $res ); $this->setTitle( NS_MAIN ); - $this->setUserPerm( "createpage" ); + $this->overrideUserPermissions( $this->user, "createpage" ); $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); $this->assertEquals( [], $res ); $this->setTitle( NS_MAIN ); - $this->setUserPerm( "createtalk" ); + $this->overrideUserPermissions( $this->user, "createtalk" ); $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); $this->assertEquals( [ [ 'nocreate-loggedin' ] ], $res ); $this->setTitle( NS_MAIN ); - $this->setUserPerm( "" ); + $this->overrideUserPermissions( $this->user ); $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); $this->assertEquals( [ [ 'nocreate-loggedin' ] ], $res ); $this->setUser( 'anon' ); $this->setTitle( NS_USER, $this->userName . '' ); - $this->setUserPerm( "" ); + $this->overrideUserPermissions( $this->user ); $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); $this->assertEquals( [ [ 'cant-move-user-page' ], [ 'movenologintext' ] ], $res ); $this->setTitle( NS_USER, $this->userName . '/subpage' ); - $this->setUserPerm( "" ); + $this->overrideUserPermissions( $this->user ); $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); $this->assertEquals( [ [ 'movenologintext' ] ], $res ); $this->setTitle( NS_USER, $this->userName . '' ); - $this->setUserPerm( "move-rootuserpages" ); + $this->overrideUserPermissions( $this->user, "move-rootuserpages" ); $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); $this->assertEquals( [ [ 'movenologintext' ] ], $res ); $this->setTitle( NS_USER, $this->userName . '/subpage' ); - $this->setUserPerm( "move-rootuserpages" ); + $this->overrideUserPermissions( $this->user, "move-rootuserpages" ); $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); $this->assertEquals( [ [ 'movenologintext' ] ], $res ); $this->setTitle( NS_USER, $this->userName . '' ); - $this->setUserPerm( "" ); + $this->overrideUserPermissions( $this->user, "" ); $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); $this->assertEquals( [ [ 'cant-move-user-page' ], [ 'movenologintext' ] ], $res ); $this->setTitle( NS_USER, $this->userName . '/subpage' ); - $this->setUserPerm( "" ); + $this->overrideUserPermissions( $this->user, "" ); $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); $this->assertEquals( [ [ 'movenologintext' ] ], $res ); $this->setTitle( NS_USER, $this->userName . '' ); - $this->setUserPerm( "move-rootuserpages" ); + $this->overrideUserPermissions( $this->user, "move-rootuserpages" ); $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); $this->assertEquals( [ [ 'movenologintext' ] ], $res ); $this->setTitle( NS_USER, $this->userName . '/subpage' ); - $this->setUserPerm( "move-rootuserpages" ); + $this->overrideUserPermissions( $this->user, "move-rootuserpages" ); $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); $this->assertEquals( [ [ 'movenologintext' ] ], $res ); $this->setUser( $this->userName ); $this->setTitle( NS_FILE, "img.png" ); - $this->setUserPerm( "" ); + $this->overrideUserPermissions( $this->user ); $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); $this->assertEquals( [ [ 'movenotallowedfile' ], [ 'movenotallowed' ] ], $res ); $this->setTitle( NS_FILE, "img.png" ); - $this->setUserPerm( "movefile" ); + $this->overrideUserPermissions( $this->user, "movefile" ); $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); $this->assertEquals( [ [ 'movenotallowed' ] ], $res ); $this->setUser( 'anon' ); $this->setTitle( NS_FILE, "img.png" ); - $this->setUserPerm( "" ); + $this->overrideUserPermissions( $this->user ); $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); $this->assertEquals( [ [ 'movenotallowedfile' ], [ 'movenologintext' ] ], $res ); $this->setTitle( NS_FILE, "img.png" ); - $this->setUserPerm( "movefile" ); + $this->overrideUserPermissions( $this->user, "movefile" ); $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); $this->assertEquals( [ [ 'movenologintext' ] ], $res ); $this->setUser( $this->userName ); - $this->setUserPerm( "move" ); + $this->overrideUserPermissions( $this->user, "move" ); $this->runGroupPermissions( 'move', [ [ 'movenotallowedfile' ] ] ); - $this->setUserPerm( "" ); + $this->overrideUserPermissions( $this->user ); $this->runGroupPermissions( 'move', [ [ 'movenotallowedfile' ], [ 'movenotallowed' ] ] ); $this->setUser( 'anon' ); - $this->setUserPerm( "move" ); + $this->overrideUserPermissions( $this->user, "move" ); $this->runGroupPermissions( 'move', [ [ 'movenotallowedfile' ] ] ); - $this->setUserPerm( "" ); + $this->overrideUserPermissions( $this->user ); $this->runGroupPermissions( 'move', [ [ 'movenotallowedfile' ], [ 'movenotallowed' ] ], @@ -259,51 +249,51 @@ class TitlePermissionTest extends MediaWikiLangTestCase { $this->setTitle( NS_MAIN ); $this->setUser( 'anon' ); - $this->setUserPerm( "move" ); + $this->overrideUserPermissions( $this->user, "move" ); $this->runGroupPermissions( 'move', [] ); - $this->setUserPerm( "" ); + $this->overrideUserPermissions( $this->user, "" ); $this->runGroupPermissions( 'move', [ [ 'movenotallowed' ] ], [ [ 'movenologintext' ] ] ); $this->setUser( $this->userName ); - $this->setUserPerm( "" ); + $this->overrideUserPermissions( $this->user, "" ); $this->runGroupPermissions( 'move', [ [ 'movenotallowed' ] ] ); - $this->setUserPerm( "move" ); + $this->overrideUserPermissions( $this->user, "move" ); $this->runGroupPermissions( 'move', [] ); $this->setUser( 'anon' ); - $this->setUserPerm( 'move' ); + $this->overrideUserPermissions( $this->user, 'move' ); $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user ); $this->assertEquals( [], $res ); - $this->setUserPerm( '' ); + $this->overrideUserPermissions( $this->user ); $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user ); $this->assertEquals( [ [ 'movenotallowed' ] ], $res ); } $this->setTitle( NS_USER ); $this->setUser( $this->userName ); - $this->setUserPerm( [ "move", "move-rootuserpages" ] ); + $this->overrideUserPermissions( $this->user, [ "move", "move-rootuserpages" ] ); $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user ); $this->assertEquals( [], $res ); - $this->setUserPerm( "move" ); + $this->overrideUserPermissions( $this->user, "move" ); $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user ); $this->assertEquals( [ [ 'cant-move-to-user-page' ] ], $res ); $this->setUser( 'anon' ); - $this->setUserPerm( [ "move", "move-rootuserpages" ] ); + $this->overrideUserPermissions( $this->user, [ "move", "move-rootuserpages" ] ); $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user ); $this->assertEquals( [], $res ); $this->setTitle( NS_USER, "User/subpage" ); - $this->setUserPerm( [ "move", "move-rootuserpages" ] ); + $this->overrideUserPermissions( $this->user, [ "move", "move-rootuserpages" ] ); $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user ); $this->assertEquals( [], $res ); - $this->setUserPerm( "move" ); + $this->overrideUserPermissions( $this->user, "move" ); $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user ); $this->assertEquals( [], $res ); @@ -329,7 +319,7 @@ class TitlePermissionTest extends MediaWikiLangTestCase { ]; foreach ( [ "edit", "protect", "" ] as $action ) { - $this->setUserPerm( null ); + $this->overrideUserPermissions( $this->user ); $this->assertEquals( $check[$action][0], $this->title->getUserPermissionsErrors( $action, $this->user, true ) ); $this->assertEquals( $check[$action][0], @@ -341,15 +331,19 @@ class TitlePermissionTest extends MediaWikiLangTestCase { $old = $wgGroupPermissions; $wgGroupPermissions = []; + $this->resetServices(); + $this->assertEquals( $check[$action][1], $this->title->getUserPermissionsErrors( $action, $this->user, true ) ); $this->assertEquals( $check[$action][1], $this->title->getUserPermissionsErrors( $action, $this->user, 'full' ) ); $this->assertEquals( $check[$action][1], $this->title->getUserPermissionsErrors( $action, $this->user, 'secure' ) ); + $wgGroupPermissions = $old; + $this->resetServices(); - $this->setUserPerm( $action ); + $this->overrideUserPermissions( $this->user, $action ); $this->assertEquals( $check[$action][2], $this->title->getUserPermissionsErrors( $action, $this->user, true ) ); $this->assertEquals( $check[$action][2], @@ -357,7 +351,7 @@ class TitlePermissionTest extends MediaWikiLangTestCase { $this->assertEquals( $check[$action][2], $this->title->getUserPermissionsErrors( $action, $this->user, 'secure' ) ); - $this->setUserPerm( $action ); + $this->overrideUserPermissions( $this->user, $action ); $this->assertEquals( $check[$action][3], $this->title->userCan( $action, $this->user, true ) ); $this->assertEquals( $check[$action][3], @@ -373,23 +367,39 @@ class TitlePermissionTest extends MediaWikiLangTestCase { $result2 = $result; } + // XXX: there could be a better way to handle this, but since we need to + // override PermissionManager service each time globals are changed + // and in the same time we need to keep user permissions overrides from the outside + // the best we can do inside this method is to save & restore faked user perms + + $userPermsOverrides = MediaWikiServices::getInstance()->getPermissionManager() + ->getUserPermissions( $this->user ); + $wgGroupPermissions['autoconfirmed']['move'] = false; $wgGroupPermissions['user']['move'] = false; + $this->resetServices(); + $this->overrideUserPermissions( $this->user, $userPermsOverrides ); $res = $this->title->getUserPermissionsErrors( $action, $this->user ); $this->assertEquals( $result, $res ); $wgGroupPermissions['autoconfirmed']['move'] = true; $wgGroupPermissions['user']['move'] = false; + $this->resetServices(); + $this->overrideUserPermissions( $this->user, $userPermsOverrides ); $res = $this->title->getUserPermissionsErrors( $action, $this->user ); $this->assertEquals( $result2, $res ); $wgGroupPermissions['autoconfirmed']['move'] = true; $wgGroupPermissions['user']['move'] = true; + $this->resetServices(); + $this->overrideUserPermissions( $this->user, $userPermsOverrides ); $res = $this->title->getUserPermissionsErrors( $action, $this->user ); $this->assertEquals( $result2, $res ); $wgGroupPermissions['autoconfirmed']['move'] = false; $wgGroupPermissions['user']['move'] = true; + $this->resetServices(); + $this->overrideUserPermissions( $this->user, $userPermsOverrides ); $res = $this->title->getUserPermissionsErrors( $action, $this->user ); $this->assertEquals( $result2, $res ); } @@ -409,42 +419,42 @@ class TitlePermissionTest extends MediaWikiLangTestCase { $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); $this->setTitle( NS_MAIN ); - $this->setUserPerm( 'bogus' ); + $this->overrideUserPermissions( $this->user, 'bogus' ); $this->assertEquals( [], $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); $this->setTitle( NS_MAIN ); - $this->setUserPerm( '' ); + $this->overrideUserPermissions( $this->user ); $this->assertEquals( [ [ 'badaccess-group0' ] ], $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); $wgNamespaceProtection[NS_USER] = [ 'bogus' ]; $this->setTitle( NS_USER ); - $this->setUserPerm( '' ); + $this->overrideUserPermissions( $this->user ); $this->assertEquals( [ [ 'badaccess-group0' ], [ 'namespaceprotected', 'User', 'bogus' ] ], $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); $this->setTitle( NS_MEDIAWIKI ); - $this->setUserPerm( 'bogus' ); + $this->overrideUserPermissions( $this->user, 'bogus' ); $this->assertEquals( [ [ 'protectedinterface', 'bogus' ] ], $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); $this->setTitle( NS_MEDIAWIKI ); - $this->setUserPerm( 'bogus' ); + $this->overrideUserPermissions( $this->user, 'bogus' ); $this->assertEquals( [ [ 'protectedinterface', 'bogus' ] ], $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); $wgNamespaceProtection = null; - $this->setUserPerm( 'bogus' ); + $this->overrideUserPermissions( $this->user, 'bogus' ); $this->assertEquals( [], $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); $this->assertEquals( true, $this->title->userCan( 'bogus', $this->user ) ); - $this->setUserPerm( '' ); + $this->overrideUserPermissions( $this->user ); $this->assertEquals( [ [ 'badaccess-group0' ] ], $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); $this->assertEquals( false, @@ -645,39 +655,39 @@ class TitlePermissionTest extends MediaWikiLangTestCase { $resultUserJs, $resultPatrol ) { - $this->setUserPerm( '' ); + $this->overrideUserPermissions( $this->user ); $result = $this->title->getUserPermissionsErrors( 'bogus', $this->user ); $this->assertEquals( $resultNone, $result ); - $this->setUserPerm( 'editmyusercss' ); + $this->overrideUserPermissions( $this->user, 'editmyusercss' ); $result = $this->title->getUserPermissionsErrors( 'bogus', $this->user ); $this->assertEquals( $resultMyCss, $result ); - $this->setUserPerm( 'editmyuserjson' ); + $this->overrideUserPermissions( $this->user, 'editmyuserjson' ); $result = $this->title->getUserPermissionsErrors( 'bogus', $this->user ); $this->assertEquals( $resultMyJson, $result ); - $this->setUserPerm( 'editmyuserjs' ); + $this->overrideUserPermissions( $this->user, 'editmyuserjs' ); $result = $this->title->getUserPermissionsErrors( 'bogus', $this->user ); $this->assertEquals( $resultMyJs, $result ); - $this->setUserPerm( 'editusercss' ); + $this->overrideUserPermissions( $this->user, 'editusercss' ); $result = $this->title->getUserPermissionsErrors( 'bogus', $this->user ); $this->assertEquals( $resultUserCss, $result ); - $this->setUserPerm( 'edituserjson' ); + $this->overrideUserPermissions( $this->user, 'edituserjson' ); $result = $this->title->getUserPermissionsErrors( 'bogus', $this->user ); $this->assertEquals( $resultUserJson, $result ); - $this->setUserPerm( 'edituserjs' ); + $this->overrideUserPermissions( $this->user, 'edituserjs' ); $result = $this->title->getUserPermissionsErrors( 'bogus', $this->user ); $this->assertEquals( $resultUserJs, $result ); - $this->setUserPerm( '' ); + $this->overrideUserPermissions( $this->user ); $result = $this->title->getUserPermissionsErrors( 'patrol', $this->user ); $this->assertEquals( reset( $resultPatrol[0] ), reset( $result[0] ) ); - $this->setUserPerm( [ 'edituserjs', 'edituserjson', 'editusercss' ] ); + $this->overrideUserPermissions( $this->user, [ 'edituserjs', 'edituserjson', 'editusercss' ] ); $result = $this->title->getUserPermissionsErrors( 'bogus', $this->user ); $this->assertEquals( [ [ 'badaccess-group0' ] ], $result ); } @@ -697,7 +707,7 @@ class TitlePermissionTest extends MediaWikiLangTestCase { $this->setTitle( NS_MAIN ); $this->title->mRestrictionsLoaded = true; - $this->setUserPerm( "edit" ); + $this->overrideUserPermissions( $this->user, "edit" ); $this->title->mRestrictions = [ "bogus" => [ 'bogus', "sysop", "protect", "" ] ]; $this->assertEquals( [], @@ -720,7 +730,7 @@ class TitlePermissionTest extends MediaWikiLangTestCase { [ 'protectedpagetext', 'protect', 'edit' ] ], $this->title->getUserPermissionsErrors( 'edit', $this->user ) ); - $this->setUserPerm( "" ); + $this->overrideUserPermissions( $this->user ); $this->assertEquals( [ [ 'badaccess-group0' ], [ 'protectedpagetext', 'bogus', 'bogus' ], [ 'protectedpagetext', 'editprotected', 'bogus' ], @@ -733,7 +743,7 @@ class TitlePermissionTest extends MediaWikiLangTestCase { [ 'protectedpagetext', 'protect', 'edit' ] ], $this->title->getUserPermissionsErrors( 'edit', $this->user ) ); - $this->setUserPerm( [ "edit", "editprotected" ] ); + $this->overrideUserPermissions( $this->user, [ "edit", "editprotected" ] ); $this->assertEquals( [ [ 'badaccess-group0' ], [ 'protectedpagetext', 'bogus', 'bogus' ], [ 'protectedpagetext', 'protect', 'bogus' ] ], @@ -746,7 +756,7 @@ class TitlePermissionTest extends MediaWikiLangTestCase { $this->user ) ); $this->title->mCascadeRestriction = true; - $this->setUserPerm( "edit" ); + $this->overrideUserPermissions( $this->user, "edit" ); $this->assertEquals( false, $this->title->quickUserCan( 'bogus', $this->user ) ); $this->assertEquals( false, @@ -763,7 +773,7 @@ class TitlePermissionTest extends MediaWikiLangTestCase { $this->title->getUserPermissionsErrors( 'edit', $this->user ) ); - $this->setUserPerm( [ "edit", "editprotected" ] ); + $this->overrideUserPermissions( $this->user, [ "edit", "editprotected" ] ); $this->assertEquals( false, $this->title->quickUserCan( 'bogus', $this->user ) ); $this->assertEquals( false, @@ -786,7 +796,7 @@ class TitlePermissionTest extends MediaWikiLangTestCase { */ public function testCascadingSourcesRestrictions() { $this->setTitle( NS_MAIN, "test page" ); - $this->setUserPerm( [ "edit", "bogus" ] ); + $this->overrideUserPermissions( $this->user, [ "edit", "bogus" ] ); $this->title->mCascadeSources = [ Title::makeTitle( NS_MAIN, "Bogus" ), @@ -816,7 +826,7 @@ class TitlePermissionTest extends MediaWikiLangTestCase { * @covers \MediaWiki\Permissions\PermissionManager::checkActionPermissions */ public function testActionPermissions() { - $this->setUserPerm( [ "createpage" ] ); + $this->overrideUserPermissions( $this->user, [ "createpage" ] ); $this->setTitle( NS_MAIN, "test page" ); $this->title->mTitleProtection['permission'] = ''; $this->title->mTitleProtection['user'] = $this->user->getId(); @@ -830,26 +840,26 @@ class TitlePermissionTest extends MediaWikiLangTestCase { $this->title->userCan( 'create', $this->user ) ); $this->title->mTitleProtection['permission'] = 'editprotected'; - $this->setUserPerm( [ 'createpage', 'protect' ] ); + $this->overrideUserPermissions( $this->user, [ 'createpage', 'protect' ] ); $this->assertEquals( [ [ 'titleprotected', 'Useruser', 'test' ] ], $this->title->getUserPermissionsErrors( 'create', $this->user ) ); $this->assertEquals( false, $this->title->userCan( 'create', $this->user ) ); - $this->setUserPerm( [ 'createpage', 'editprotected' ] ); + $this->overrideUserPermissions( $this->user, [ 'createpage', 'editprotected' ] ); $this->assertEquals( [], $this->title->getUserPermissionsErrors( 'create', $this->user ) ); $this->assertEquals( true, $this->title->userCan( 'create', $this->user ) ); - $this->setUserPerm( [ 'createpage' ] ); + $this->overrideUserPermissions( $this->user, [ 'createpage' ] ); $this->assertEquals( [ [ 'titleprotected', 'Useruser', 'test' ] ], $this->title->getUserPermissionsErrors( 'create', $this->user ) ); $this->assertEquals( false, $this->title->userCan( 'create', $this->user ) ); $this->setTitle( NS_MEDIA, "test page" ); - $this->setUserPerm( [ "move" ] ); + $this->overrideUserPermissions( $this->user, [ "move" ] ); $this->assertEquals( false, $this->title->userCan( 'move', $this->user ) ); $this->assertEquals( [ [ 'immobile-source-namespace', 'Media' ] ], @@ -895,9 +905,12 @@ class TitlePermissionTest extends MediaWikiLangTestCase { 'wgEmailAuthentication' => true, 'wgBlockDisablesLogin' => false, ] ); - $this->overrideMwServices(); + $this->resetServices(); - $this->setUserPerm( [ 'createpage', 'edit', 'move', 'rollback', 'patrol', 'upload', 'purge' ] ); + $this->overrideUserPermissions( + $this->user, + [ 'createpage', 'edit', 'move', 'rollback', 'patrol', 'upload', 'purge' ] + ); $this->setTitle( NS_HELP, "test page" ); # $wgEmailConfirmToEdit only applies to 'edit' action @@ -907,7 +920,11 @@ class TitlePermissionTest extends MediaWikiLangTestCase { $this->title->getUserPermissionsErrors( 'edit', $this->user ) ); $this->setMwGlobals( 'wgEmailConfirmToEdit', false ); - $this->overrideMwServices(); + $this->resetServices(); + $this->overrideUserPermissions( + $this->user, + [ 'createpage', 'edit', 'move', 'rollback', 'patrol', 'upload', 'purge' ] + ); $this->assertNotContains( [ 'confirmedittext' ], $this->title->getUserPermissionsErrors( 'edit', $this->user ) ); @@ -931,8 +948,8 @@ class TitlePermissionTest extends MediaWikiLangTestCase { ] ); $this->user->mBlock->setTimestamp( 0 ); $this->assertEquals( [ [ 'autoblockedtext', - '[[User:Useruser|Useruser]]', 'no reason given', '127.0.0.1', - 'Useruser', null, 'infinite', '127.0.8.1', + "[[User:Useruser|\u{202A}Useruser\u{202C}]]", 'no reason given', '127.0.0.1', + "\u{202A}Useruser\u{202C}", null, 'infinite', '127.0.8.1', $wgLang->timeanddate( wfTimestamp( TS_MW, $prev ), true ) ] ], $this->title->getUserPermissionsErrors( 'move-target', $this->user ) ); @@ -953,8 +970,8 @@ class TitlePermissionTest extends MediaWikiLangTestCase { 'expiry' => 10, ] ); $this->assertEquals( [ [ 'blockedtext', - '[[User:Useruser|Useruser]]', 'no reason given', '127.0.0.1', - 'Useruser', null, '23:00, 31 December 1969', '127.0.8.1', + "[[User:Useruser|\u{202A}Useruser\u{202C}]]", 'no reason given', '127.0.0.1', + "\u{202A}Useruser\u{202C}", null, '23:00, 31 December 1969', '127.0.8.1', $wgLang->timeanddate( wfTimestamp( TS_MW, $now ), true ) ] ], $this->title->getUserPermissionsErrors( 'move-target', $this->user ) ); # $action != 'read' && $action != 'createaccount' && $user->isBlockedFrom( $this ) @@ -971,8 +988,8 @@ class TitlePermissionTest extends MediaWikiLangTestCase { ] ); $errors = [ [ 'systemblockedtext', - '[[User:Useruser|Useruser]]', 'no reason given', '127.0.0.1', - 'Useruser', 'test', 'infinite', '127.0.8.1', + "[[User:Useruser|\u{202A}Useruser\u{202C}]]", 'no reason given', '127.0.0.1', + "\u{202A}Useruser\u{202C}", 'test', 'infinite', '127.0.8.1', $wgLang->timeanddate( wfTimestamp( TS_MW, $now ), true ) ] ]; $this->assertEquals( $errors, @@ -1017,8 +1034,8 @@ class TitlePermissionTest extends MediaWikiLangTestCase { ] ); $errors = [ [ 'blockedtext-partial', - '[[User:Useruser|Useruser]]', 'no reason given', '127.0.0.1', - 'Useruser', null, '23:00, 31 December 1969', '127.0.8.1', + "[[User:Useruser|\u{202A}Useruser\u{202C}]]", 'no reason given', '127.0.0.1', + "\u{202A}Useruser\u{202C}", null, '23:00, 31 December 1969', '127.0.8.1', $wgLang->timeanddate( wfTimestamp( TS_MW, $now ), true ) ] ]; $this->assertEquals( $errors, @@ -1071,6 +1088,7 @@ class TitlePermissionTest extends MediaWikiLangTestCase { ], ], ] ); + $this->resetServices(); $now = time(); $this->user->mBlockedby = $this->user->getName(); @@ -1084,8 +1102,8 @@ class TitlePermissionTest extends MediaWikiLangTestCase { ] ); $errors = [ [ 'blockedtext', - '[[User:Useruser|Useruser]]', 'no reason given', '127.0.0.1', - 'Useruser', null, 'infinite', '127.0.8.1', + "[[User:Useruser|\u{202A}Useruser\u{202C}]]", 'no reason given', '127.0.0.1', + "\u{202A}Useruser\u{202C}", null, 'infinite', '127.0.8.1', $wgLang->timeanddate( wfTimestamp( TS_MW, $now ), true ) ] ]; $this->assertEquals( $errors, diff --git a/tests/phpunit/includes/TitleTest.php b/tests/phpunit/includes/TitleTest.php index d6c34014f7..913f56de55 100644 --- a/tests/phpunit/includes/TitleTest.php +++ b/tests/phpunit/includes/TitleTest.php @@ -1,5 +1,6 @@ mRights = []; + $this->overrideUserPermissions( $user, [] ); $errors = $title->userCan( $action, $user ); if ( is_bool( $expected ) ) { @@ -494,17 +495,31 @@ class TitleTest extends MediaWikiTestCase { */ public function testGetBaseText( $title, $expected, $msg = '' ) { $title = Title::newFromText( $title ); - $this->assertEquals( $expected, + $this->assertSame( $expected, $title->getBaseText(), $msg ); } + /** + * @dataProvider provideBaseTitleCases + * @covers Title::getBaseTitle + */ + public function testGetBaseTitle( $title, $expected, $msg = '' ) { + $title = Title::newFromText( $title ); + $base = $title->getBaseTitle(); + $this->assertTrue( $base->isValid(), $msg ); + $this->assertTrue( + $base->equals( Title::makeTitleSafe( $title->getNamespace(), $expected ) ), + $msg + ); + } + public static function provideBaseTitleCases() { return [ # Title, expected base, optional message [ 'User:John_Doe/subOne/subTwo', 'John Doe/subOne' ], - [ 'User:Foo/Bar/Baz', 'Foo/Bar' ], + [ 'User:Foo / Bar / Baz', 'Foo / Bar ' ], ]; } @@ -520,11 +535,29 @@ class TitleTest extends MediaWikiTestCase { ); } + /** + * @dataProvider provideRootTitleCases + * @covers Title::getRootTitle + */ + public function testGetRootTitle( $title, $expected, $msg = '' ) { + $title = Title::newFromText( $title ); + $root = $title->getRootTitle(); + $this->assertTrue( $root->isValid(), $msg ); + $this->assertTrue( + $root->equals( Title::makeTitleSafe( $title->getNamespace(), $expected ) ), + $msg + ); + } + public static function provideRootTitleCases() { return [ # Title, expected base, optional message [ 'User:John_Doe/subOne/subTwo', 'John Doe' ], - [ 'User:Foo/Bar/Baz', 'Foo' ], + [ 'User:Foo / Bar / Baz', 'Foo ' ], + [ 'Talk:////', '////' ], + [ 'Template:////', '////' ], + [ 'Template:Foo////', 'Foo' ], + [ 'Template:Foo////Bar', 'Foo' ], ]; } @@ -549,6 +582,41 @@ class TitleTest extends MediaWikiTestCase { ]; } + public function provideSubpage() { + // NOTE: avoid constructing Title objects in the provider, since it may access the database. + return [ + [ 'Foo', 'x', new TitleValue( NS_MAIN, 'Foo/x' ) ], + [ 'Foo#bar', 'x', new TitleValue( NS_MAIN, 'Foo/x' ) ], + [ 'User:Foo', 'x', new TitleValue( NS_USER, 'Foo/x' ) ], + [ 'wiki:User:Foo', 'x', new TitleValue( NS_MAIN, 'User:Foo/x', '', 'wiki' ) ], + ]; + } + + /** + * @dataProvider provideSubpage + * @covers Title::getSubpage + */ + public function testSubpage( $title, $sub, LinkTarget $expected ) { + $interwikiLookup = $this->getMock( InterwikiLookup::class ); + $interwikiLookup->expects( $this->any() ) + ->method( 'isValidInterwiki' ) + ->willReturnCallback( + function ( $prefix ) { + return $prefix == 'wiki'; + } + ); + + $this->setService( 'InterwikiLookup', $interwikiLookup ); + + $title = Title::newFromText( $title ); + $expected = Title::newFromLinkTarget( $expected ); + $actual = $title->getSubpage( $sub ); + + // NOTE: convert to string for comparison + $this->assertSame( $expected->getPrefixedText(), $actual->getPrefixedText(), 'text form' ); + $this->assertTrue( $expected->equals( $actual ), 'Title equality' ); + } + public static function provideNewFromTitleValue() { return [ [ new TitleValue( NS_MAIN, 'Foo' ) ], @@ -709,6 +777,12 @@ class TitleTest extends MediaWikiTestCase { [ Title::makeTitle( NS_MAIN, '|' ), false ], [ Title::makeTitle( NS_MAIN, '#' ), false ], [ Title::makeTitle( NS_MAIN, 'Test' ), true ], + [ Title::makeTitle( NS_MAIN, ' Test' ), false ], + [ Title::makeTitle( NS_MAIN, '_Test' ), false ], + [ Title::makeTitle( NS_MAIN, 'Test ' ), false ], + [ Title::makeTitle( NS_MAIN, 'Test_' ), false ], + [ Title::makeTitle( NS_MAIN, "Test\nthis" ), false ], + [ Title::makeTitle( NS_MAIN, "Test\tthis" ), false ], [ Title::makeTitle( -33, 'Test' ), false ], [ Title::makeTitle( 77663399, 'Test' ), false ], ]; @@ -766,6 +840,65 @@ class TitleTest extends MediaWikiTestCase { 'Virtual namespace cannot have talk page' => [ Title::makeTitle( NS_MEDIA, 'Kitten.jpg' ), false ], + 'Relative link has no talk page' => [ + Title::makeTitle( NS_MAIN, '', 'Kittens' ), false + ], + 'Interwiki link has no talk page' => [ + Title::makeTitle( NS_MAIN, 'Kittens', '', 'acme' ), false + ], + ]; + } + + public function provideIsWatchable() { + return [ + 'User page is watchable' => [ + Title::makeTitle( NS_USER, 'Jane' ), true + ], + 'Talke page is watchable' => [ + Title::makeTitle( NS_TALK, 'Foo' ), true + ], + 'Special page is not watchable' => [ + Title::makeTitle( NS_SPECIAL, 'Thing' ), false + ], + 'Virtual namespace is not watchable' => [ + Title::makeTitle( NS_MEDIA, 'Kitten.jpg' ), false + ], + 'Relative link is not watchable' => [ + Title::makeTitle( NS_MAIN, '', 'Kittens' ), false + ], + 'Interwiki link is not watchable' => [ + Title::makeTitle( NS_MAIN, 'Kittens', '', 'acme' ), false + ], + ]; + } + + public static function provideGetTalkPage_good() { + return [ + [ Title::makeTitle( NS_MAIN, 'Test' ), Title::makeTitle( NS_TALK, 'Test' ) ], + [ Title::makeTitle( NS_TALK, 'Test' ), Title::makeTitle( NS_TALK, 'Test' ) ], + ]; + } + + public static function provideGetTalkPage_bad() { + return [ + [ Title::makeTitle( NS_SPECIAL, 'Test' ) ], + [ Title::makeTitle( NS_MEDIA, 'Test' ) ], + [ Title::makeTitle( NS_MAIN, '', 'Kittens' ) ], + [ Title::makeTitle( NS_MAIN, 'Kittens', '', 'acme' ) ], + ]; + } + + public static function provideGetSubjectPage_good() { + return [ + [ Title::makeTitle( NS_TALK, 'Test' ), Title::makeTitle( NS_MAIN, 'Test' ) ], + [ Title::makeTitle( NS_MAIN, 'Test' ), Title::makeTitle( NS_MAIN, 'Test' ) ], + ]; + } + + public static function provideGetOtherPage_good() { + return [ + [ Title::makeTitle( NS_MAIN, 'Test' ), Title::makeTitle( NS_TALK, 'Test' ) ], + [ Title::makeTitle( NS_TALK, 'Test' ), Title::makeTitle( NS_MAIN, 'Test' ) ], ]; } @@ -781,31 +914,44 @@ class TitleTest extends MediaWikiTestCase { $this->assertSame( $expected, $actual, $title->getPrefixedDBkey() ); } - public static function provideGetTalkPage_good() { - return [ - [ Title::makeTitle( NS_MAIN, 'Test' ), Title::makeTitle( NS_TALK, 'Test' ) ], - [ Title::makeTitle( NS_TALK, 'Test' ), Title::makeTitle( NS_TALK, 'Test' ) ], - ]; + /** + * @dataProvider provideIsWatchable + * @covers Title::isWatchable + * + * @param Title $title + * @param bool $expected + */ + public function testIsWatchable( Title $title, $expected ) { + $actual = $title->canHaveTalkPage(); + $this->assertSame( $expected, $actual, $title->getPrefixedDBkey() ); } /** * @dataProvider provideGetTalkPage_good * @covers Title::getTalkPageIfDefined */ - public function testGetTalkPageIfDefined_good( Title $title ) { - $talk = $title->getTalkPageIfDefined(); - $this->assertInstanceOf( - Title::class, - $talk, - $title->getPrefixedDBKey() - ); + public function testGetTalkPage_good( Title $title, Title $expected ) { + $actual = $title->getTalkPage(); + $this->assertTrue( $expected->equals( $actual ), $title->getPrefixedDBkey() ); } - public static function provideGetTalkPage_bad() { - return [ - [ Title::makeTitle( NS_SPECIAL, 'Test' ) ], - [ Title::makeTitle( NS_MEDIA, 'Test' ) ], - ]; + /** + * @dataProvider provideGetTalkPage_bad + * @covers Title::getTalkPageIfDefined + */ + public function testGetTalkPage_bad( Title $title ) { + $this->setExpectedException( MWException::class ); + $title->getTalkPage(); + } + + /** + * @dataProvider provideGetTalkPage_good + * @covers Title::getTalkPageIfDefined + */ + public function testGetTalkPageIfDefined_good( Title $title, Title $expected ) { + $actual = $title->getTalkPageIfDefined(); + $this->assertNotNull( $actual, $title->getPrefixedDBkey() ); + $this->assertTrue( $expected->equals( $actual ), $title->getPrefixedDBkey() ); } /** @@ -816,10 +962,37 @@ class TitleTest extends MediaWikiTestCase { $talk = $title->getTalkPageIfDefined(); $this->assertNull( $talk, - $title->getPrefixedDBKey() + $title->getPrefixedDBkey() ); } + /** + * @dataProvider provideGetSubjectPage_good + * @covers Title::getSubjectPage + */ + public function testGetSubjectPage_good( Title $title, Title $expected ) { + $actual = $title->getSubjectPage(); + $this->assertTrue( $expected->equals( $actual ), $title->getPrefixedDBkey() ); + } + + /** + * @dataProvider provideGetOtherPage_good + * @covers Title::getOtherPage + */ + public function testGetOtherPage_good( Title $title, Title $expected ) { + $actual = $title->getOtherPage(); + $this->assertTrue( $expected->equals( $actual ), $title->getPrefixedDBkey() ); + } + + /** + * @dataProvider provideGetTalkPage_bad + * @covers Title::getOtherPage + */ + public function testGetOtherPage_bad( Title $title ) { + $this->setExpectedException( MWException::class ); + $title->getOtherPage(); + } + public function provideCreateFragmentTitle() { return [ [ Title::makeTitle( NS_MAIN, 'Test' ), 'foo' ], diff --git a/tests/phpunit/includes/WikiMapTest.php b/tests/phpunit/includes/WikiMapTest.php index 6850a24545..6fe9218b7f 100644 --- a/tests/phpunit/includes/WikiMapTest.php +++ b/tests/phpunit/includes/WikiMapTest.php @@ -236,7 +236,7 @@ class WikiMapTest extends MediaWikiLangTestCase { $this->assertEquals( $wiki, WikiMap::getWikiFromUrl( $url ) ); } - public function provideGetWikiIdFromDomain() { + public function provideGetWikiIdFromDbDomain() { return [ [ 'db-prefix_', 'db-prefix_' ], [ wfWikiID(), wfWikiID() ], @@ -249,10 +249,10 @@ class WikiMapTest extends MediaWikiLangTestCase { } /** - * @dataProvider provideGetWikiIdFromDomain + * @dataProvider provideGetWikiIdFromDbDomain * @covers WikiMap::getWikiIdFromDbDomain() */ - public function testGetWikiIdFromDomain( $domain, $wikiId ) { + public function testGetWikiIdFromDbDomain( $domain, $wikiId ) { $this->assertEquals( $wikiId, WikiMap::getWikiIdFromDbDomain( $domain ) ); } diff --git a/tests/phpunit/includes/WikiReferenceTest.php b/tests/phpunit/includes/WikiReferenceTest.php deleted file mode 100644 index e4b21ce5ac..0000000000 --- a/tests/phpunit/includes/WikiReferenceTest.php +++ /dev/null @@ -1,166 +0,0 @@ - [ 'foo.bar', 'http://foo.bar' ], - 'https' => [ 'foo.bar', 'http://foo.bar' ], - - // apparently, this is the expected behavior - 'invalid' => [ 'purple kittens', 'purple kittens' ], - ]; - } - - /** - * @dataProvider provideGetDisplayName - */ - public function testGetDisplayName( $expected, $canonicalServer ) { - $reference = new WikiReference( $canonicalServer, '/wiki/$1' ); - $this->assertEquals( $expected, $reference->getDisplayName() ); - } - - public function testGetCanonicalServer() { - $reference = new WikiReference( 'https://acme.com', '/wiki/$1', '//acme.com' ); - $this->assertEquals( 'https://acme.com', $reference->getCanonicalServer() ); - } - - public function provideGetCanonicalUrl() { - return [ - 'no fragment' => [ - 'https://acme.com/wiki/Foo', - 'https://acme.com', - '//acme.com', - '/wiki/$1', - 'Foo', - null - ], - 'empty fragment' => [ - 'https://acme.com/wiki/Foo', - 'https://acme.com', - '//acme.com', - '/wiki/$1', - 'Foo', - '' - ], - 'fragment' => [ - 'https://acme.com/wiki/Foo#Bar', - 'https://acme.com', - '//acme.com', - '/wiki/$1', - 'Foo', - 'Bar' - ], - 'double fragment' => [ - 'https://acme.com/wiki/Foo#Bar%23Xus', - 'https://acme.com', - '//acme.com', - '/wiki/$1', - 'Foo', - 'Bar#Xus' - ], - 'escaped fragment' => [ - 'https://acme.com/wiki/Foo%23Bar', - 'https://acme.com', - '//acme.com', - '/wiki/$1', - 'Foo#Bar', - null - ], - 'empty path' => [ - 'https://acme.com/Foo', - 'https://acme.com', - '//acme.com', - '/$1', - 'Foo', - null - ], - ]; - } - - /** - * @dataProvider provideGetCanonicalUrl - */ - public function testGetCanonicalUrl( - $expected, $canonicalServer, $server, $path, $page, $fragmentId - ) { - $reference = new WikiReference( $canonicalServer, $path, $server ); - $this->assertEquals( $expected, $reference->getCanonicalUrl( $page, $fragmentId ) ); - } - - /** - * @dataProvider provideGetCanonicalUrl - * @note getUrl is an alias for getCanonicalUrl - */ - public function testGetUrl( $expected, $canonicalServer, $server, $path, $page, $fragmentId ) { - $reference = new WikiReference( $canonicalServer, $path, $server ); - $this->assertEquals( $expected, $reference->getUrl( $page, $fragmentId ) ); - } - - public function provideGetFullUrl() { - return [ - 'no fragment' => [ - '//acme.com/wiki/Foo', - 'https://acme.com', - '//acme.com', - '/wiki/$1', - 'Foo', - null - ], - 'empty fragment' => [ - '//acme.com/wiki/Foo', - 'https://acme.com', - '//acme.com', - '/wiki/$1', - 'Foo', - '' - ], - 'fragment' => [ - '//acme.com/wiki/Foo#Bar', - 'https://acme.com', - '//acme.com', - '/wiki/$1', - 'Foo', - 'Bar' - ], - 'double fragment' => [ - '//acme.com/wiki/Foo#Bar%23Xus', - 'https://acme.com', - '//acme.com', - '/wiki/$1', - 'Foo', - 'Bar#Xus' - ], - 'escaped fragment' => [ - '//acme.com/wiki/Foo%23Bar', - 'https://acme.com', - '//acme.com', - '/wiki/$1', - 'Foo#Bar', - null - ], - 'empty path' => [ - '//acme.com/Foo', - 'https://acme.com', - '//acme.com', - '/$1', - 'Foo', - null - ], - ]; - } - - /** - * @dataProvider provideGetFullUrl - */ - public function testGetFullUrl( $expected, $canonicalServer, $server, $path, $page, $fragmentId ) { - $reference = new WikiReference( $canonicalServer, $path, $server ); - $this->assertEquals( $expected, $reference->getFullUrl( $page, $fragmentId ) ); - } - -} diff --git a/tests/phpunit/includes/XmlJsTest.php b/tests/phpunit/includes/XmlJsTest.php deleted file mode 100644 index c7975efabc..0000000000 --- a/tests/phpunit/includes/XmlJsTest.php +++ /dev/null @@ -1,26 +0,0 @@ -assertEquals( $value, $obj->value ); - } - - public static function provideConstruction() { - return [ - [ null ], - [ '' ], - ]; - } - -} diff --git a/tests/phpunit/includes/XmlSelectTest.php b/tests/phpunit/includes/XmlSelectTest.php deleted file mode 100644 index 52e20bdb99..0000000000 --- a/tests/phpunit/includes/XmlSelectTest.php +++ /dev/null @@ -1,182 +0,0 @@ -select = new XmlSelect(); - } - - protected function tearDown() { - parent::tearDown(); - $this->select = null; - } - - /** - * @covers XmlSelect::__construct - */ - public function testConstructWithoutParameters() { - $this->assertEquals( '', $this->select->getHTML() ); - } - - /** - * Parameters are $name (false), $id (false), $default (false) - * @dataProvider provideConstructionParameters - * @covers XmlSelect::__construct - */ - public function testConstructParameters( $name, $id, $default, $expected ) { - $this->select = new XmlSelect( $name, $id, $default ); - $this->assertEquals( $expected, $this->select->getHTML() ); - } - - /** - * Provide parameters for testConstructParameters() which use three - * parameters: - * - $name (default: false) - * - $id (default: false) - * - $default (default: false) - * Provides a fourth parameters representing the expected HTML output - */ - public static function provideConstructionParameters() { - return [ - /** - * Values are set following a 3-bit Gray code where two successive - * values differ by only one value. - * See https://en.wikipedia.org/wiki/Gray_code - */ - # $name $id $default - [ false, false, false, '' ], - [ false, false, 'foo', '' ], - [ false, 'id', 'foo', '' ], - [ false, 'id', false, '' ], - [ 'name', 'id', false, '' ], - [ 'name', 'id', 'foo', '' ], - [ 'name', false, 'foo', '' ], - [ 'name', false, false, '' ], - ]; - } - - /** - * @covers XmlSelect::addOption - */ - public function testAddOption() { - $this->select->addOption( 'foo' ); - $this->assertEquals( - '', - $this->select->getHTML() - ); - } - - /** - * @covers XmlSelect::addOption - */ - public function testAddOptionWithDefault() { - $this->select->addOption( 'foo', true ); - $this->assertEquals( - '', - $this->select->getHTML() - ); - } - - /** - * @covers XmlSelect::addOption - */ - public function testAddOptionWithFalse() { - $this->select->addOption( 'foo', false ); - $this->assertEquals( - '', - $this->select->getHTML() - ); - } - - /** - * @covers XmlSelect::addOption - */ - public function testAddOptionWithValueZero() { - $this->select->addOption( 'foo', 0 ); - $this->assertEquals( - '', - $this->select->getHTML() - ); - } - - /** - * @covers XmlSelect::setDefault - */ - public function testSetDefault() { - $this->select->setDefault( 'bar1' ); - $this->select->addOption( 'foo1' ); - $this->select->addOption( 'bar1' ); - $this->select->addOption( 'foo2' ); - $this->assertEquals( - '', $this->select->getHTML() ); - } - - /** - * Adding default later on should set the correct selection or - * raise an exception. - * To handle this, we need to render the options in getHtml() - * @covers XmlSelect::setDefault - */ - public function testSetDefaultAfterAddingOptions() { - $this->select->addOption( 'foo1' ); - $this->select->addOption( 'bar1' ); - $this->select->addOption( 'foo2' ); - $this->select->setDefault( 'bar1' ); # setting default after adding options - $this->assertEquals( - '', $this->select->getHTML() ); - } - - /** - * @covers XmlSelect::setAttribute - * @covers XmlSelect::getAttribute - */ - public function testGetAttributes() { - # create some attributes - $this->select->setAttribute( 'dummy', 0x777 ); - $this->select->setAttribute( 'string', 'euro €' ); - $this->select->setAttribute( 1911, 'razor' ); - - # verify we can retrieve them - $this->assertEquals( - $this->select->getAttribute( 'dummy' ), - 0x777 - ); - $this->assertEquals( - $this->select->getAttribute( 'string' ), - 'euro €' - ); - $this->assertEquals( - $this->select->getAttribute( 1911 ), - 'razor' - ); - - # inexistent keys should give us 'null' - $this->assertEquals( - $this->select->getAttribute( 'I DO NOT EXIT' ), - null - ); - - # verify string / integer - $this->assertEquals( - $this->select->getAttribute( '1911' ), - 'razor' - ); - $this->assertEquals( - $this->select->getAttribute( 'dummy' ), - 0x777 - ); - } -} diff --git a/tests/phpunit/includes/actions/ActionTest.php b/tests/phpunit/includes/actions/ActionTest.php index 5ad773681d..4d977cbf1e 100644 --- a/tests/phpunit/includes/actions/ActionTest.php +++ b/tests/phpunit/includes/actions/ActionTest.php @@ -190,14 +190,14 @@ class ActionTest extends MediaWikiTestCase { public function testCanExecute() { $user = $this->getTestUser()->getUser(); - $user->mRights = [ 'access' ]; + $this->overrideUserPermissions( $user, 'access' ); $action = Action::factory( 'access', $this->getPage(), $this->getContext() ); $this->assertNull( $action->canExecute( $user ) ); } public function testCanExecuteNoRight() { $user = $this->getTestUser()->getUser(); - $user->mRights = []; + $this->overrideUserPermissions( $user, [] ); $action = Action::factory( 'access', $this->getPage(), $this->getContext() ); try { @@ -209,7 +209,7 @@ class ActionTest extends MediaWikiTestCase { public function testCanExecuteRequiresUnblock() { $user = $this->getTestUser()->getUser(); - $user->mRights = []; + $this->overrideUserPermissions( $user, [] ); $page = $this->getExistingTestPage(); $action = Action::factory( 'unblock', $page, $this->getContext() ); diff --git a/tests/phpunit/includes/api/ApiBlockTest.php b/tests/phpunit/includes/api/ApiBlockTest.php index 43da9a95ba..b29d333cb2 100644 --- a/tests/phpunit/includes/api/ApiBlockTest.php +++ b/tests/phpunit/includes/api/ApiBlockTest.php @@ -150,6 +150,8 @@ class ApiBlockTest extends ApiTestCase { $this->setMwGlobals( 'wgRevokePermissions', [ 'user' => [ 'applychangetags' => true ] ] ); + $this->resetServices(); + $this->doBlock( [ 'tags' => 'custom tag' ] ); } @@ -160,6 +162,7 @@ class ApiBlockTest extends ApiTestCase { $this->mergeMwGlobalArrayValue( 'wgGroupPermissions', [ 'sysop' => $newPermissions ] ); + $this->resetServices(); $res = $this->doBlock( [ 'hidename' => '' ] ); $dbw = wfGetDB( DB_MASTER ); @@ -209,6 +212,8 @@ class ApiBlockTest extends ApiTestCase { $this->setMwGlobals( 'wgRevokePermissions', [ 'sysop' => [ 'blockemail' => true ] ] ); + $this->resetServices(); + $this->doBlock( [ 'noemail' => '' ] ); } diff --git a/tests/phpunit/includes/api/ApiCSPReportTest.php b/tests/phpunit/includes/api/ApiCSPReportTest.php new file mode 100644 index 0000000000..b3e0543c78 --- /dev/null +++ b/tests/phpunit/includes/api/ApiCSPReportTest.php @@ -0,0 +1,120 @@ +setMwGlobals( [ + 'CSPFalsePositiveUrls' => [], + ] ); + } + + public function testInternalReportonly() { + $params = [ + 'reportonly' => '1', + 'source' => 'internal', + ]; + $cspReport = [ + 'document-uri' => 'https://doc.test/path', + 'referrer' => 'https://referrer.test/path', + 'violated-directive' => 'connet-src', + 'disposition' => 'report', + 'blocked-uri' => 'https://blocked.test/path?query', + 'line-number' => 4, + 'column-number' => 2, + 'source-file' => 'https://source.test/path?query', + ]; + + $log = $this->doExecute( $params, $cspReport ); + + $this->assertEquals( + [ + [ + '[report-only] Received CSP report: ' . + ' blocked from being loaded on :4', + [ + 'method' => 'ApiCSPReport::execute', + 'user_id' => 'logged-out', + 'user-agent' => 'Test/0.0', + 'source' => 'internal' + ] + ], + ], + $log, + 'logged messages' + ); + } + + public function testFalsePositiveOriginMatch() { + $params = [ + 'reportonly' => '1', + 'source' => 'internal', + ]; + $cspReport = [ + 'document-uri' => 'https://doc.test/path', + 'referrer' => 'https://referrer.test/path', + 'violated-directive' => 'connet-src', + 'disposition' => 'report', + 'blocked-uri' => 'https://blocked.test/path/file?query', + 'line-number' => 4, + 'column-number' => 2, + 'source-file' => 'https://source.test/path/file?query', + ]; + + $this->setMwGlobals( [ + 'wgCSPFalsePositiveUrls' => [ + 'https://blocked.test/path/' => true, + ], + ] ); + $log = $this->doExecute( $params, $cspReport ); + + $this->assertSame( + [], + $log, + 'logged messages' + ); + } + + private function doExecute( array $params, array $cspReport ) { + $log = []; + $logger = $this->createMock( Psr\Log\AbstractLogger::class ); + $logger->method( 'warning' )->will( $this->returnCallback( + function ( $msg, $ctx ) use ( &$log ) { + unset( $ctx['csp-report'] ); + $log[] = [ $msg, $ctx ]; + } + ) ); + $this->setLogger( 'csp-report-only', $logger ); + + $postBody = json_encode( [ 'csp-report' => $cspReport ] ); + $req = $this->getMockBuilder( FauxRequest::class ) + ->setMethods( [ 'getRawInput' ] ) + ->setConstructorArgs( [ $params, /* $wasPosted */ true ] ) + ->getMock(); + $req->method( 'getRawInput' )->willReturn( $postBody ); + $req->setHeaders( [ + 'Content-Type' => 'application/csp-report', + 'User-Agent' => 'Test/0.0' + ] ); + + $api = $this->getMockBuilder( ApiCSPReport::class ) + ->disableOriginalConstructor() + ->setMethods( [ 'getParameter', 'getRequest', 'getResult' ] ) + ->getMock(); + $api->method( 'getParameter' )->will( $this->returnCallback( + function ( $key ) use ( $req ) { + return $req->getRawVal( $key ); + } + ) ); + $api->method( 'getRequest' )->willReturn( $req ); + $api->method( 'getResult' )->willReturn( new ApiResult( false ) ); + + $api->execute(); + return $log; + } +} diff --git a/tests/phpunit/includes/api/ApiDeleteTest.php b/tests/phpunit/includes/api/ApiDeleteTest.php index c68954c077..cc5dadaa93 100644 --- a/tests/phpunit/includes/api/ApiDeleteTest.php +++ b/tests/phpunit/includes/api/ApiDeleteTest.php @@ -143,6 +143,7 @@ class ApiDeleteTest extends ApiTestCase { ChangeTags::defineTag( 'custom tag' ); $this->setMwGlobals( 'wgRevokePermissions', [ 'user' => [ 'applychangetags' => true ] ] ); + $this->resetServices(); $this->editPage( $name, 'Some text' ); diff --git a/tests/phpunit/includes/api/ApiEditPageTest.php b/tests/phpunit/includes/api/ApiEditPageTest.php index d2762e0893..5e5fea321f 100644 --- a/tests/phpunit/includes/api/ApiEditPageTest.php +++ b/tests/phpunit/includes/api/ApiEditPageTest.php @@ -39,6 +39,7 @@ class ApiEditPageTest extends ApiTestCase { $this->tablesUsed, [ 'change_tag', 'change_tag_def', 'logging' ] ); + $this->resetServices(); } public function testEdit() { @@ -1367,69 +1368,21 @@ class ApiEditPageTest extends ApiTestCase { ChangeTags::defineTag( 'custom tag' ); $this->setMwGlobals( 'wgRevokePermissions', [ 'user' => [ 'applychangetags' => true ] ] ); - try { - $this->doApiRequestWithToken( [ - 'action' => 'edit', - 'title' => $name, - 'text' => 'Some text', - 'tags' => 'custom tag', - ] ); - } finally { - $this->assertFalse( Title::newFromText( $name )->exists() ); - } - } - - public function testEditAbortedByHook() { - $name = 'Help:' . ucfirst( __FUNCTION__ ); - - $this->setExpectedException( ApiUsageException::class, - 'The modification you tried to make was aborted by an extension.' ); - - $this->hideDeprecated( 'APIEditBeforeSave hook (used in ' . - 'hook-APIEditBeforeSave-closure)' ); - - $this->setTemporaryHook( 'APIEditBeforeSave', - function () { - return false; - } - ); + // Supply services with updated globals + $this->resetServices(); try { $this->doApiRequestWithToken( [ 'action' => 'edit', 'title' => $name, 'text' => 'Some text', + 'tags' => 'custom tag', ] ); } finally { $this->assertFalse( Title::newFromText( $name )->exists() ); } } - public function testEditAbortedByHookWithCustomOutput() { - $name = 'Help:' . ucfirst( __FUNCTION__ ); - - $this->hideDeprecated( 'APIEditBeforeSave hook (used in ' . - 'hook-APIEditBeforeSave-closure)' ); - - $this->setTemporaryHook( 'APIEditBeforeSave', - function ( $unused1, $unused2, &$r ) { - $r['msg'] = 'Some message'; - return false; - } ); - - $result = $this->doApiRequestWithToken( [ - 'action' => 'edit', - 'title' => $name, - 'text' => 'Some text', - ] ); - Wikimedia\restoreWarnings(); - - $this->assertSame( [ 'msg' => 'Some message', 'result' => 'Failure' ], - $result[0]['edit'] ); - - $this->assertFalse( Title::newFromText( $name )->exists() ); - } - public function testEditAbortedByEditPageHookWithResult() { $name = 'Help:' . ucfirst( __FUNCTION__ ); @@ -1545,6 +1498,8 @@ class ApiEditPageTest extends ApiTestCase { $this->setMwGlobals( 'wgRevokePermissions', [ 'user' => [ 'upload' => true ] ] ); + // Supply services with updated globals + $this->resetServices(); $this->doApiRequestWithToken( [ 'action' => 'edit', @@ -1560,6 +1515,8 @@ class ApiEditPageTest extends ApiTestCase { 'The content you supplied exceeds the article size limit of 1 kilobyte.' ); $this->setMwGlobals( 'wgMaxArticleSize', 1 ); + // Supply services with updated globals + $this->resetServices(); $text = str_repeat( '!', 1025 ); @@ -1577,6 +1534,8 @@ class ApiEditPageTest extends ApiTestCase { 'The action you have requested is limited to users in the group: ' ); $this->setMwGlobals( 'wgRevokePermissions', [ '*' => [ 'edit' => true ] ] ); + // Supply services with updated globals + $this->resetServices(); $this->doApiRequestWithToken( [ 'action' => 'edit', @@ -1593,6 +1552,8 @@ class ApiEditPageTest extends ApiTestCase { $this->setMwGlobals( 'wgRevokePermissions', [ 'user' => [ 'editcontentmodel' => true ] ] ); + // Supply services with updated globals + $this->resetServices(); $this->doApiRequestWithToken( [ 'action' => 'edit', diff --git a/tests/phpunit/includes/api/ApiMainTest.php b/tests/phpunit/includes/api/ApiMainTest.php index a5518a1252..580efcd933 100644 --- a/tests/phpunit/includes/api/ApiMainTest.php +++ b/tests/phpunit/includes/api/ApiMainTest.php @@ -141,6 +141,7 @@ class ApiMainTest extends ApiTestCase { public function testSetCacheModeUnrecognized() { $api = new ApiMain(); $api->setCacheMode( 'unrecognized' ); + $this->resetServices(); $this->assertSame( 'private', TestingAccessWrapper::newFromObject( $api )->mCacheMode, @@ -150,7 +151,6 @@ class ApiMainTest extends ApiTestCase { public function testSetCacheModePrivateWiki() { $this->setGroupPermissions( '*', 'read', false ); - $wrappedApi = TestingAccessWrapper::newFromObject( new ApiMain() ); $wrappedApi->setCacheMode( 'public' ); $this->assertSame( 'private', $wrappedApi->mCacheMode ); @@ -401,7 +401,7 @@ class ApiMainTest extends ApiTestCase { } else { $user = new User(); } - $user->mRights = $rights; + $this->overrideUserPermissions( $user, $rights ); try { $this->doApiRequest( [ 'action' => 'query', diff --git a/tests/phpunit/includes/api/ApiMoveTest.php b/tests/phpunit/includes/api/ApiMoveTest.php index d880923d3f..c98308cc88 100644 --- a/tests/phpunit/includes/api/ApiMoveTest.php +++ b/tests/phpunit/includes/api/ApiMoveTest.php @@ -294,6 +294,7 @@ class ApiMoveTest extends ApiTestCase { $name = ucfirst( __FUNCTION__ ); $this->mergeMwGlobalArrayValue( 'wgNamespacesWithSubpages', [ NS_MAIN => true ] ); + $this->resetServices(); $pages = [ $name, "$name/1", "$name/2", "Talk:$name", "Talk:$name/1", "Talk:$name/3" ]; $ids = []; @@ -379,7 +380,6 @@ class ApiMoveTest extends ApiTestCase { $name = ucfirst( __FUNCTION__ ); $this->setGroupPermissions( 'sysop', 'suppressredirect', false ); - $id = $this->createPage( $name ); $res = $this->doApiRequestWithToken( [ diff --git a/tests/phpunit/includes/api/ApiParseTest.php b/tests/phpunit/includes/api/ApiParseTest.php index 0011d7a2c8..a87160a1d8 100644 --- a/tests/phpunit/includes/api/ApiParseTest.php +++ b/tests/phpunit/includes/api/ApiParseTest.php @@ -121,7 +121,7 @@ class ApiParseTest extends ApiTestCase { $this->setMwGlobals( 'wgExtraInterlanguageLinkPrefixes', [ 'madeuplanguage' ] ); $this->tablesUsed[] = 'interwiki'; - $this->overrideMwServices(); + $this->resetServices(); } /** diff --git a/tests/phpunit/includes/api/ApiQueryLanguageinfoTest.php b/tests/phpunit/includes/api/ApiQueryLanguageinfoTest.php index f4bab029f5..6bbdd3bd53 100644 --- a/tests/phpunit/includes/api/ApiQueryLanguageinfoTest.php +++ b/tests/phpunit/includes/api/ApiQueryLanguageinfoTest.php @@ -1,10 +1,8 @@ [ [ 'utwiki' => [ 'Qwerty' ] ], [ - SearchResultSet::SECONDARY_RESULTS => [ + ISearchResultSet::SECONDARY_RESULTS => [ 'utwiki' => new MockSearchResultSet( [ $this->mockResultClosure( 'Qwerty', diff --git a/tests/phpunit/includes/api/ApiQueryWatchlistRawIntegrationTest.php b/tests/phpunit/includes/api/ApiQueryWatchlistRawIntegrationTest.php index 2af63c4983..c554fb3f49 100644 --- a/tests/phpunit/includes/api/ApiQueryWatchlistRawIntegrationTest.php +++ b/tests/phpunit/includes/api/ApiQueryWatchlistRawIntegrationTest.php @@ -476,7 +476,7 @@ class ApiQueryWatchlistRawIntegrationTest extends ApiTestCase { new TitleValue( 1, 'ApiQueryWatchlistRawIntegrationTestPage1' ), ] ); - ObjectCache::getMainWANInstance()->clearProcessCache(); + MediaWikiServices::getInstance()->getMainWANObjectCache()->clearProcessCache(); $result = $this->doListWatchlistRawRequest( [ 'wrowner' => $otherUser->getName(), 'wrtoken' => '1234567890', diff --git a/tests/phpunit/includes/api/ApiStashEditTest.php b/tests/phpunit/includes/api/ApiStashEditTest.php index c6ed8a7879..ecb7e1ec41 100644 --- a/tests/phpunit/includes/api/ApiStashEditTest.php +++ b/tests/phpunit/includes/api/ApiStashEditTest.php @@ -307,6 +307,7 @@ class ApiStashEditTest extends ApiTestCase { // Nor does the original one if they become a bot $user->addGroup( 'bot' ); + MediaWikiServices::getInstance()->getPermissionManager()->invalidateUsersRightsCache(); $this->assertFalse( $this->doCheckCache( $user ), "We assume bots don't have cache entries" @@ -315,6 +316,7 @@ class ApiStashEditTest extends ApiTestCase { // But other groups are okay $user->removeGroup( 'bot' ); $user->addGroup( 'sysop' ); + MediaWikiServices::getInstance()->getPermissionManager()->invalidateUsersRightsCache(); $this->assertInstanceOf( stdClass::class, $this->doCheckCache( $user ) ); } @@ -347,8 +349,6 @@ class ApiStashEditTest extends ApiTestCase { $cache = $editStash->cache; $editInfo = $cache->get( $key ); - $outputKey = $cache->makeKey( 'stashed-edit-output', $editInfo->outputID ); - $editInfo->output = $cache->get( $outputKey ); $editInfo->output->setCacheTime( wfTimestamp( TS_MW, wfTimestamp( TS_UNIX, $editInfo->output->getCacheTime() ) - $howOld - 1 ) ); diff --git a/tests/phpunit/includes/api/ApiUserrightsTest.php b/tests/phpunit/includes/api/ApiUserrightsTest.php index a1bafed78c..0d7ad0c502 100644 --- a/tests/phpunit/includes/api/ApiUserrightsTest.php +++ b/tests/phpunit/includes/api/ApiUserrightsTest.php @@ -1,6 +1,7 @@ mergeMwGlobalArrayValue( 'wgRemoveGroups', [ 'bureaucrat' => $remove ] ); } + + $this->resetServices(); } /** @@ -75,6 +78,7 @@ class ApiUserrightsTest extends ApiTestCase { $res = $this->doApiRequestWithToken( $params ); $user->clearInstanceCache(); + MediaWikiServices::getInstance()->getPermissionManager()->invalidateUsersRightsCache(); $this->assertSame( $expectedGroups, $user->getGroups() ); $this->assertArrayNotHasKey( 'warnings', $res[0] ); @@ -217,6 +221,7 @@ class ApiUserrightsTest extends ApiTestCase { ChangeTags::defineTag( 'custom tag' ); $this->setGroupPermissions( 'user', 'applychangetags', false ); + $this->resetServices(); $this->doFailedRightsChange( 'You do not have permission to apply change tags along with your changes.', diff --git a/tests/phpunit/includes/api/query/ApiQueryTestBase.php b/tests/phpunit/includes/api/query/ApiQueryTestBase.php index 7869bbd2db..71a77b62ab 100644 --- a/tests/phpunit/includes/api/query/ApiQueryTestBase.php +++ b/tests/phpunit/includes/api/query/ApiQueryTestBase.php @@ -86,7 +86,7 @@ STR; /** * Checks that the request's result matches the expected results. * Assumes no rawcontinue and a complete batch. - * @param array $values Array is a two element array( request, expected_results ) + * @param array $values Array is a two element [ request, expected_results ] * @param array|null $session * @param bool $appendModule * @param User|null $user diff --git a/tests/phpunit/includes/auth/AuthManagerTest.php b/tests/phpunit/includes/auth/AuthManagerTest.php index e6a1d3886c..fc6f688091 100644 --- a/tests/phpunit/includes/auth/AuthManagerTest.php +++ b/tests/phpunit/includes/auth/AuthManagerTest.php @@ -1446,6 +1446,7 @@ class AuthManagerTest extends \MediaWikiTestCase { ]; $block = new DatabaseBlock( $blockOptions ); $block->insert(); + $this->resetServices(); $status = $this->manager->checkAccountCreatePermissions( $user ); $this->assertFalse( $status->isOK() ); $this->assertTrue( $status->hasMessage( 'cantcreateaccount-text' ) ); @@ -1472,12 +1473,12 @@ class AuthManagerTest extends \MediaWikiTestCase { ], 'wgProxyWhitelist' => [], ] ); - $this->overrideMwServices(); + $this->resetServices(); $status = $this->manager->checkAccountCreatePermissions( new \User ); $this->assertFalse( $status->isOK() ); $this->assertTrue( $status->hasMessage( 'sorbs_create_account_reason' ) ); $this->setMwGlobals( 'wgProxyWhitelist', [ '127.0.0.1' ] ); - $this->overrideMwServices(); + $this->resetServices(); $status = $this->manager->checkAccountCreatePermissions( new \User ); $this->assertTrue( $status->isGood() ); } @@ -2365,6 +2366,8 @@ class AuthManagerTest extends \MediaWikiTestCase { $this->mergeMwGlobalArrayValue( 'wgObjectCaches', [ __METHOD__ => [ 'class' => 'HashBagOStuff' ] ] ); $this->setMwGlobals( [ 'wgMainCacheType' => __METHOD__ ] ); + // Supply services with updated globals + $this->resetServices(); // Set up lots of mocks... $mocks = []; diff --git a/tests/phpunit/includes/auth/AuthenticationResponseTest.php b/tests/phpunit/includes/auth/AuthenticationResponseTest.php deleted file mode 100644 index c79682275f..0000000000 --- a/tests/phpunit/includes/auth/AuthenticationResponseTest.php +++ /dev/null @@ -1,112 +0,0 @@ -messageType = 'warning'; - foreach ( $expect as $field => $value ) { - $res->$field = $value; - } - $ret = call_user_func_array( "MediaWiki\\Auth\\AuthenticationResponse::$constructor", $args ); - $this->assertEquals( $res, $ret ); - } else { - try { - call_user_func_array( "MediaWiki\\Auth\\AuthenticationResponse::$constructor", $args ); - $this->fail( 'Expected exception not thrown' ); - } catch ( \Exception $ex ) { - $this->assertEquals( $expect, $ex ); - } - } - } - - public function provideConstructors() { - $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); - $msg = new \Message( 'mainpage' ); - - return [ - [ 'newPass', [], [ - 'status' => AuthenticationResponse::PASS, - ] ], - [ 'newPass', [ 'name' ], [ - 'status' => AuthenticationResponse::PASS, - 'username' => 'name', - ] ], - [ 'newPass', [ 'name', null ], [ - 'status' => AuthenticationResponse::PASS, - 'username' => 'name', - ] ], - - [ 'newFail', [ $msg ], [ - 'status' => AuthenticationResponse::FAIL, - 'message' => $msg, - 'messageType' => 'error', - ] ], - - [ 'newRestart', [ $msg ], [ - 'status' => AuthenticationResponse::RESTART, - 'message' => $msg, - ] ], - - [ 'newAbstain', [], [ - 'status' => AuthenticationResponse::ABSTAIN, - ] ], - - [ 'newUI', [ [ $req ], $msg ], [ - 'status' => AuthenticationResponse::UI, - 'neededRequests' => [ $req ], - 'message' => $msg, - 'messageType' => 'warning', - ] ], - - [ 'newUI', [ [ $req ], $msg, 'warning' ], [ - 'status' => AuthenticationResponse::UI, - 'neededRequests' => [ $req ], - 'message' => $msg, - 'messageType' => 'warning', - ] ], - - [ 'newUI', [ [ $req ], $msg, 'error' ], [ - 'status' => AuthenticationResponse::UI, - 'neededRequests' => [ $req ], - 'message' => $msg, - 'messageType' => 'error', - ] ], - [ 'newUI', [ [], $msg ], - new \InvalidArgumentException( '$reqs may not be empty' ) - ], - - [ 'newRedirect', [ [ $req ], 'http://example.org/redir' ], [ - 'status' => AuthenticationResponse::REDIRECT, - 'neededRequests' => [ $req ], - 'redirectTarget' => 'http://example.org/redir', - ] ], - [ - 'newRedirect', - [ [ $req ], 'http://example.org/redir', [ 'foo' => 'bar' ] ], - [ - 'status' => AuthenticationResponse::REDIRECT, - 'neededRequests' => [ $req ], - 'redirectTarget' => 'http://example.org/redir', - 'redirectApiData' => [ 'foo' => 'bar' ], - ] - ], - [ 'newRedirect', [ [], 'http://example.org/redir' ], - new \InvalidArgumentException( '$reqs may not be empty' ) - ], - ]; - } - -} diff --git a/tests/phpunit/includes/auth/LocalPasswordPrimaryAuthenticationProviderTest.php b/tests/phpunit/includes/auth/LocalPasswordPrimaryAuthenticationProviderTest.php index 6d831f6a0a..4f875ce861 100644 --- a/tests/phpunit/includes/auth/LocalPasswordPrimaryAuthenticationProviderTest.php +++ b/tests/phpunit/includes/auth/LocalPasswordPrimaryAuthenticationProviderTest.php @@ -336,15 +336,6 @@ class LocalPasswordPrimaryAuthenticationProviderTest extends \MediaWikiTestCase ); // Correct handling of really old password hashes - $this->config->set( 'PasswordSalt', false ); - $password = md5( 'FooBar' ); - $dbw->update( 'user', [ 'user_password' => $password ], [ 'user_name' => $userName ] ); - $req->password = 'FooBar'; - $this->assertEquals( - AuthenticationResponse::newPass( $userName ), - $provider->beginPrimaryAuthentication( $reqs ) - ); - $this->config->set( 'PasswordSalt', true ); $password = md5( "$id-" . md5( 'FooBar' ) ); $dbw->update( 'user', [ 'user_password' => $password ], [ 'user_name' => $userName ] ); diff --git a/tests/phpunit/includes/block/BlockManagerTest.php b/tests/phpunit/includes/block/BlockManagerTest.php index 39a5534027..f42777c503 100644 --- a/tests/phpunit/includes/block/BlockManagerTest.php +++ b/tests/phpunit/includes/block/BlockManagerTest.php @@ -2,6 +2,11 @@ use MediaWiki\Block\BlockManager; use MediaWiki\Block\DatabaseBlock; +use MediaWiki\Block\CompositeBlock; +use MediaWiki\Block\SystemBlock; +use MediaWiki\Config\ServiceOptions; +use MediaWiki\MediaWikiServices; +use Wikimedia\TestingAccessWrapper; /** * @group Blocking @@ -35,39 +40,48 @@ class BlockManagerTest extends MediaWikiTestCase { } private function getBlockManager( $overrideConfig ) { - $blockManagerConfig = array_merge( $this->blockManagerConfig, $overrideConfig ); return new BlockManager( - $this->user, - $this->user->getRequest(), - ...array_values( $blockManagerConfig ) + ...$this->getBlockManagerConstructorArgs( $overrideConfig ) ); } + private function getBlockManagerConstructorArgs( $overrideConfig ) { + $blockManagerConfig = array_merge( $this->blockManagerConfig, $overrideConfig ); + $this->setMwGlobals( $blockManagerConfig ); + $this->overrideMwServices(); + return [ + new ServiceOptions( + BlockManager::$constructorOptions, + MediaWikiServices::getInstance()->getMainConfig() + ), + $this->user, + $this->user->getRequest() + ]; + } + /** * @dataProvider provideGetBlockFromCookieValue * @covers ::getBlockFromCookieValue + * @covers ::shouldApplyCookieBlock */ public function testGetBlockFromCookieValue( $options, $expected ) { - $blockManager = $this->getBlockManager( [ - 'wgCookieSetOnAutoblock' => true, - 'wgCookieSetOnIpBlock' => true, - ] ); + $blockManager = TestingAccessWrapper::newFromObject( + $this->getBlockManager( [ + 'wgCookieSetOnAutoblock' => true, + 'wgCookieSetOnIpBlock' => true, + ] ) + ); $block = new DatabaseBlock( array_merge( [ - 'address' => $options[ 'target' ] ?: $this->user, + 'address' => $options['target'] ?: $this->user, 'by' => $this->sysopId, - ], $options[ 'blockOptions' ] ) ); + ], $options['blockOptions'] ) ); $block->insert(); - $class = new ReflectionClass( BlockManager::class ); - $method = $class->getMethod( 'getBlockFromCookieValue' ); - $method->setAccessible( true ); - - $user = $options[ 'loggedIn' ] ? $this->user : new User(); + $user = $options['loggedIn'] ? $this->user : new User(); $user->getRequest()->setCookie( 'BlockID', $block->getCookieValue() ); - $this->assertSame( $expected, (bool)$method->invoke( - $blockManager, + $this->assertSame( $expected, (bool)$blockManager->getBlockFromCookieValue( $user, $user->getRequest() ) ); @@ -127,16 +141,14 @@ class BlockManagerTest extends MediaWikiTestCase { * @covers ::isLocallyBlockedProxy */ public function testIsLocallyBlockedProxy( $proxyList, $expected ) { - $class = new ReflectionClass( BlockManager::class ); - $method = $class->getMethod( 'isLocallyBlockedProxy' ); - $method->setAccessible( true ); - - $blockManager = $this->getBlockManager( [ - 'wgProxyList' => $proxyList - ] ); + $blockManager = TestingAccessWrapper::newFromObject( + $this->getBlockManager( [ + 'wgProxyList' => $proxyList + ] ) + ); $ip = '1.2.3.4'; - $this->assertSame( $expected, $method->invoke( $blockManager, $ip ) ); + $this->assertSame( $expected, $blockManager->isLocallyBlockedProxy( $ip ) ); } public static function provideIsLocallyBlockedProxy() { @@ -148,52 +160,23 @@ class BlockManagerTest extends MediaWikiTestCase { ]; } - /** - * @covers ::isLocallyBlockedProxy - */ - public function testIsLocallyBlockedProxyDeprecated() { - $proxy = '1.2.3.4'; - - $this->hideDeprecated( - 'IP addresses in the keys of $wgProxyList (found the following IP ' . - 'addresses in keys: ' . $proxy . ', please move them to values)' - ); - - $class = new ReflectionClass( BlockManager::class ); - $method = $class->getMethod( 'isLocallyBlockedProxy' ); - $method->setAccessible( true ); - - $blockManager = $this->getBlockManager( [ - 'wgProxyList' => [ $proxy => 'test' ] - ] ); - - $ip = '1.2.3.4'; - $this->assertSame( true, $method->invoke( $blockManager, $ip ) ); - } - /** * @dataProvider provideIsDnsBlacklisted * @covers ::isDnsBlacklisted * @covers ::inDnsBlacklist */ public function testIsDnsBlacklisted( $options, $expected ) { - $blockManagerConfig = array_merge( $this->blockManagerConfig, [ + $blockManagerConfig = [ 'wgEnableDnsBlacklist' => true, 'wgDnsBlacklistUrls' => $options['blacklist'], 'wgProxyWhitelist' => $options['whitelist'], - ] ); + ]; $blockManager = $this->getMockBuilder( BlockManager::class ) - ->setConstructorArgs( - array_merge( [ - $this->user, - $this->user->getRequest(), - ], $blockManagerConfig ) ) + ->setConstructorArgs( $this->getBlockManagerConstructorArgs( $blockManagerConfig ) ) ->setMethods( [ 'checkHost' ] ) ->getMock(); - - $blockManager->expects( $this->any() ) - ->method( 'checkHost' ) + $blockManager->method( 'checkHost' ) ->will( $this->returnValueMap( [ [ $options['dnsblQuery'], $options['dnsblResponse'], @@ -288,4 +271,413 @@ class BlockManagerTest extends MediaWikiTestCase { ], ]; } + + /** + * @covers ::getUniqueBlocks + */ + public function testGetUniqueBlocks() { + $blockId = 100; + + $blockManager = TestingAccessWrapper::newFromObject( $this->getBlockManager( [] ) ); + + $block = $this->getMockBuilder( DatabaseBlock::class ) + ->setMethods( [ 'getId' ] ) + ->getMock(); + $block->method( 'getId' ) + ->willReturn( $blockId ); + + $autoblock = $this->getMockBuilder( DatabaseBlock::class ) + ->setMethods( [ 'getParentBlockId', 'getType' ] ) + ->getMock(); + $autoblock->method( 'getParentBlockId' ) + ->willReturn( $blockId ); + $autoblock->method( 'getType' ) + ->willReturn( DatabaseBlock::TYPE_AUTO ); + + $blocks = [ $block, $block, $autoblock, new SystemBlock() ]; + + $this->assertSame( 2, count( $blockManager->getUniqueBlocks( $blocks ) ) ); + } + + /** + * @dataProvider provideTrackBlockWithCookie + * @covers ::trackBlockWithCookie + */ + public function testTrackBlockWithCookie( $options, $expectedVal ) { + $this->setMwGlobals( 'wgCookiePrefix', '' ); + + $request = new FauxRequest(); + if ( $options['cookieSet'] ) { + $request->setCookie( 'BlockID', 'the value does not matter' ); + } + + $user = $this->getMockBuilder( User::class ) + ->setMethods( [ 'getBlock', 'getRequest' ] ) + ->getMock(); + $user->method( 'getBlock' ) + ->willReturn( $options['block'] ); + $user->method( 'getRequest' ) + ->willReturn( $request ); + + // Although the block cookie is set via DeferredUpdates, in command line mode updates are + // processed immediately + $blockManager = $this->getBlockManager( [ + 'wgSecretKey' => '', + 'wgCookieSetOnIpBlock' => true, + ] ); + $blockManager->trackBlockWithCookie( $user ); + + /** @var FauxResponse $response */ + $response = $request->response(); + $this->assertCount( $expectedVal ? 1 : 0, $response->getCookies() ); + $this->assertEquals( $expectedVal ?: null, $response->getCookie( 'BlockID' ) ); + } + + public function provideTrackBlockWithCookie() { + $blockId = 123; + return [ + 'Block cookie is already set; there is a trackable block' => [ + [ + 'cookieSet' => true, + 'block' => $this->getTrackableBlock( $blockId ), + ], + null, + ], + 'Block cookie is already set; there is no block' => [ + [ + 'cookieSet' => true, + 'block' => null, + ], + null, + ], + 'Block cookie is not yet set; there is no block' => [ + [ + 'cookieSet' => false, + 'block' => null, + ], + null, + ], + 'Block cookie is not yet set; there is a trackable block' => [ + [ + 'cookieSet' => false, + 'block' => $this->getTrackableBlock( $blockId ), + ], + $blockId, + ], + 'Block cookie is not yet set; there is a composite block with a trackable block' => [ + [ + 'cookieSet' => false, + 'block' => new CompositeBlock( [ + 'originalBlocks' => [ + new SystemBlock(), + $this->getTrackableBlock( $blockId ), + ] + ] ), + ], + $blockId, + ], + 'Block cookie is not yet set; there is a composite block but no trackable block' => [ + [ + 'cookieSet' => false, + 'block' => new CompositeBlock( [ + 'originalBlocks' => [ + new SystemBlock(), + new SystemBlock(), + ] + ] ), + ], + null, + ], + ]; + } + + private function getTrackableBlock( $blockId ) { + $block = $this->getMockBuilder( DatabaseBlock::class ) + ->setMethods( [ 'getType', 'getId' ] ) + ->getMock(); + $block->method( 'getType' ) + ->willReturn( DatabaseBlock::TYPE_IP ); + $block->method( 'getId' ) + ->willReturn( $blockId ); + return $block; + } + + /** + * @dataProvider provideSetBlockCookie + * @covers ::setBlockCookie + */ + public function testSetBlockCookie( $expiryDelta, $expectedExpiryDelta ) { + $this->setMwGlobals( [ + 'wgCookiePrefix' => '', + ] ); + + $request = new FauxRequest(); + $response = $request->response(); + + $blockManager = $this->getBlockManager( [ + 'wgSecretKey' => '', + 'wgCookieSetOnIpBlock' => true, + ] ); + + $now = wfTimestamp(); + + $block = new DatabaseBlock( [ + 'expiry' => $expiryDelta === '' ? '' : $now + $expiryDelta + ] ); + $blockManager->setBlockCookie( $block, $response ); + $cookies = $response->getCookies(); + + $this->assertEquals( + $now + $expectedExpiryDelta, + $cookies['BlockID']['expire'], + '', + 60 // Allow actual to be up to 60 seconds later than expected + ); + } + + public static function provideSetBlockCookie() { + // Maximum length of a block cookie, defined in BlockManager::setBlockCookie + $maxExpiryDelta = ( 24 * 60 * 60 ); + + $longExpiryDelta = ( 48 * 60 * 60 ); + $shortExpiryDelta = ( 12 * 60 * 60 ); + + return [ + 'Block has indefinite expiry' => [ + '', + $maxExpiryDelta, + ], + 'Block expiry is later than maximum cookie block expiry' => [ + $longExpiryDelta, + $maxExpiryDelta, + ], + 'Block expiry is sooner than maximum cookie block expiry' => [ + $shortExpiryDelta, + $shortExpiryDelta, + ], + ]; + } + + /** + * @covers ::shouldTrackBlockWithCookie + */ + public function testShouldTrackBlockWithCookieSystemBlock() { + $blockManager = TestingAccessWrapper::newFromObject( $this->getBlockManager( [] ) ); + $this->assertFalse( $blockManager->shouldTrackBlockWithCookie( + new SystemBlock(), + true + ) ); + } + + /** + * @dataProvider provideShouldTrackBlockWithCookie + * @covers ::shouldTrackBlockWithCookie + */ + public function testShouldTrackBlockWithCookie( $options, $expected ) { + $block = $this->getMockBuilder( DatabaseBlock::class ) + ->setMethods( [ 'getType', 'isAutoblocking' ] ) + ->getMock(); + $block->method( 'getType' ) + ->willReturn( $options['type'] ); + if ( isset( $options['autoblocking'] ) ) { + $block->method( 'isAutoblocking' ) + ->willReturn( $options['autoblocking'] ); + } + + $blockManager = TestingAccessWrapper::newFromObject( + $this->getBlockManager( $options['blockManagerConfig'] ) + ); + + $this->assertSame( + $expected, + $blockManager->shouldTrackBlockWithCookie( $block, $options['isAnon'] ) + ); + } + + public static function provideShouldTrackBlockWithCookie() { + return [ + 'IP block, anonymous user, IP block cookies enabled' => [ + [ + 'type' => DatabaseBlock::TYPE_IP, + 'isAnon' => true, + 'blockManagerConfig' => [ 'wgCookieSetOnIpBlock' => true ], + ], + true + ], + 'IP range block, anonymous user, IP block cookies enabled' => [ + [ + 'type' => DatabaseBlock::TYPE_RANGE, + 'isAnon' => true, + 'blockManagerConfig' => [ 'wgCookieSetOnIpBlock' => true ], + ], + true + ], + 'IP block, anonymous user, IP block cookies disabled' => [ + [ + 'type' => DatabaseBlock::TYPE_IP, + 'isAnon' => true, + 'blockManagerConfig' => [ 'wgCookieSetOnIpBlock' => false ], + ], + false + ], + 'IP block, logged in user, IP block cookies enabled' => [ + [ + 'type' => DatabaseBlock::TYPE_IP, + 'isAnon' => false, + 'blockManagerConfig' => [ 'wgCookieSetOnIpBlock' => true ], + ], + false + ], + 'User block, anonymous, autoblock cookies enabled, block is autoblocking' => [ + [ + 'type' => DatabaseBlock::TYPE_USER, + 'isAnon' => true, + 'blockManagerConfig' => [ 'wgCookieSetOnAutoblock' => true ], + 'autoblocking' => true, + ], + false + ], + 'User block, logged in, autoblock cookies enabled, block is autoblocking' => [ + [ + 'type' => DatabaseBlock::TYPE_USER, + 'isAnon' => false, + 'blockManagerConfig' => [ 'wgCookieSetOnAutoblock' => true ], + 'autoblocking' => true, + ], + true + ], + 'User block, logged in, autoblock cookies disabled, block is autoblocking' => [ + [ + 'type' => DatabaseBlock::TYPE_USER, + 'isAnon' => false, + 'blockManagerConfig' => [ 'wgCookieSetOnAutoblock' => false ], + 'autoblocking' => true, + ], + false + ], + 'User block, logged in, autoblock cookies enabled, block is not autoblocking' => [ + [ + 'type' => DatabaseBlock::TYPE_USER, + 'isAnon' => false, + 'blockManagerConfig' => [ 'wgCookieSetOnAutoblock' => true ], + 'autoblocking' => false, + ], + false + ], + 'Block type is autoblock' => [ + [ + 'type' => DatabaseBlock::TYPE_AUTO, + 'isAnon' => true, + 'blockManagerConfig' => [], + ], + false + ] + ]; + } + + /** + * @covers ::clearBlockCookie + */ + public function testClearBlockCookie() { + $this->setMwGlobals( [ + 'wgCookiePrefix' => '', + ] ); + + $request = new FauxRequest(); + $response = $request->response(); + $response->setCookie( 'BlockID', '100' ); + $this->assertSame( '100', $response->getCookie( 'BlockID' ) ); + + BlockManager::clearBlockCookie( $response ); + $this->assertSame( '', $response->getCookie( 'BlockID' ) ); + } + + /** + * @dataProvider provideGetIdFromCookieValue + * @covers ::getIdFromCookieValue + */ + public function testGetIdFromCookieValue( $options, $expected ) { + $blockManager = $this->getBlockManager( [ + 'wgSecretKey' => $options['secretKey'] + ] ); + $this->assertEquals( + $expected, + $blockManager->getIdFromCookieValue( $options['cookieValue'] ) + ); + } + + public static function provideGetIdFromCookieValue() { + $blockId = 100; + $secretKey = '123'; + $hmac = MWCryptHash::hmac( $blockId, $secretKey, false ); + return [ + 'No secret key is set' => [ + [ + 'secretKey' => '', + 'cookieValue' => $blockId, + 'calculatedHmac' => MWCryptHash::hmac( $blockId, '', false ), + ], + $blockId, + ], + 'Secret key is set and stored hmac is correct' => [ + [ + 'secretKey' => $secretKey, + 'cookieValue' => $blockId . '!' . $hmac, + 'calculatedHmac' => $hmac, + ], + $blockId, + ], + 'Secret key is set and stored hmac is incorrect' => [ + [ + 'secretKey' => $secretKey, + 'cookieValue' => $blockId . '!xyz', + 'calculatedHmac' => $hmac, + ], + null, + ], + ]; + } + + /** + * @dataProvider provideGetCookieValue + * @covers ::getCookieValue + */ + public function testGetCookieValue( $options, $expected ) { + $blockManager = $this->getBlockManager( [ + 'wgSecretKey' => $options['secretKey'] + ] ); + + $block = $this->getMockBuilder( DatabaseBlock::class ) + ->setMethods( [ 'getId' ] ) + ->getMock(); + $block->method( 'getId' ) + ->willReturn( $options['blockId'] ); + + $this->assertEquals( + $expected, + $blockManager->getCookieValue( $block ) + ); + } + + public static function provideGetCookieValue() { + $blockId = 100; + return [ + 'Secret key not set' => [ + [ + 'secretKey' => '', + 'blockId' => $blockId, + 'hmac' => MWCryptHash::hmac( $blockId, '', false ), + ], + $blockId, + ], + 'Secret key set' => [ + [ + 'secretKey' => '123', + 'blockId' => $blockId, + 'hmac' => MWCryptHash::hmac( $blockId, '123', false ), + ], + $blockId . '!' . MWCryptHash::hmac( $blockId, '123', false ) ], + ]; + } + } diff --git a/tests/phpunit/includes/block/CompositeBlockTest.php b/tests/phpunit/includes/block/CompositeBlockTest.php new file mode 100644 index 0000000000..fe8cee76a7 --- /dev/null +++ b/tests/phpunit/includes/block/CompositeBlockTest.php @@ -0,0 +1,314 @@ +getTestSysop()->getUser()->getId(); + + $userBlock = new Block( [ + 'address' => $this->getTestUser()->getUser(), + 'by' => $sysopId, + 'sitewide' => false, + ] ); + $ipBlock = new Block( [ + 'address' => '127.0.0.1', + 'by' => $sysopId, + 'sitewide' => false, + ] ); + + $userBlock->insert(); + $ipBlock->insert(); + + return [ + 'user' => $userBlock, + 'ip' => $ipBlock, + ]; + } + + private function deleteBlocks( $blocks ) { + foreach ( $blocks as $block ) { + $block->delete(); + } + } + + /** + * @covers ::__construct + * @dataProvider provideTestStrictestParametersApplied + */ + public function testStrictestParametersApplied( $blocks, $expected ) { + $this->setMwGlobals( [ + 'wgBlockDisablesLogin' => false, + 'wgBlockAllowsUTEdit' => true, + ] ); + + $block = new CompositeBlock( [ + 'originalBlocks' => $blocks, + ] ); + + $this->assertSame( $expected[ 'hideName' ], $block->getHideName() ); + $this->assertSame( $expected[ 'sitewide' ], $block->isSitewide() ); + $this->assertSame( $expected[ 'blockEmail' ], $block->isEmailBlocked() ); + $this->assertSame( $expected[ 'allowUsertalk' ], $block->isUsertalkEditAllowed() ); + } + + public static function provideTestStrictestParametersApplied() { + return [ + 'Sitewide block and partial block' => [ + [ + new Block( [ + 'sitewide' => false, + 'blockEmail' => true, + 'allowUsertalk' => true, + ] ), + new Block( [ + 'sitewide' => true, + 'blockEmail' => false, + 'allowUsertalk' => false, + ] ), + ], + [ + 'hideName' => false, + 'sitewide' => true, + 'blockEmail' => true, + 'allowUsertalk' => false, + ], + ], + 'Partial block and system block' => [ + [ + new Block( [ + 'sitewide' => false, + 'blockEmail' => true, + 'allowUsertalk' => false, + ] ), + new SystemBlock( [ + 'systemBlock' => 'proxy', + ] ), + ], + [ + 'hideName' => false, + 'sitewide' => true, + 'blockEmail' => true, + 'allowUsertalk' => false, + ], + ], + 'System block and user name hiding block' => [ + [ + new Block( [ + 'hideName' => true, + 'sitewide' => true, + 'blockEmail' => true, + 'allowUsertalk' => false, + ] ), + new SystemBlock( [ + 'systemBlock' => 'proxy', + ] ), + ], + [ + 'hideName' => true, + 'sitewide' => true, + 'blockEmail' => true, + 'allowUsertalk' => false, + ], + ], + 'Two lenient partial blocks' => [ + [ + new Block( [ + 'sitewide' => false, + 'blockEmail' => false, + 'allowUsertalk' => true, + ] ), + new Block( [ + 'sitewide' => false, + 'blockEmail' => false, + 'allowUsertalk' => true, + ] ), + ], + [ + 'hideName' => false, + 'sitewide' => false, + 'blockEmail' => false, + 'allowUsertalk' => true, + ], + ], + ]; + } + + /** + * @covers ::appliesToTitle + */ + public function testBlockAppliesToTitle() { + $this->setMwGlobals( [ + 'wgBlockDisablesLogin' => false, + ] ); + + $blocks = $this->getPartialBlocks(); + + $block = new CompositeBlock( [ + 'originalBlocks' => $blocks, + ] ); + + $pageFoo = $this->getExistingTestPage( 'Foo' ); + $pageBar = $this->getExistingTestPage( 'User:Bar' ); + + $this->getBlockRestrictionStore()->insert( [ + new PageRestriction( $blocks[ 'user' ]->getId(), $pageFoo->getId() ), + new NamespaceRestriction( $blocks[ 'ip' ]->getId(), NS_USER ), + ] ); + + $this->assertTrue( $block->appliesToTitle( $pageFoo->getTitle() ) ); + $this->assertTrue( $block->appliesToTitle( $pageBar->getTitle() ) ); + + $this->deleteBlocks( $blocks ); + } + + /** + * @covers ::appliesToUsertalk + * @covers ::appliesToPage + * @covers ::appliesToNamespace + */ + public function testBlockAppliesToUsertalk() { + $this->setMwGlobals( [ + 'wgBlockAllowsUTEdit' => true, + 'wgBlockDisablesLogin' => false, + ] ); + + $blocks = $this->getPartialBlocks(); + + $block = new CompositeBlock( [ + 'originalBlocks' => $blocks, + ] ); + + $title = $blocks[ 'user' ]->getTarget()->getTalkPage(); + $page = $this->getExistingTestPage( 'User talk:' . $title->getText() ); + + $this->getBlockRestrictionStore()->insert( [ + new PageRestriction( $blocks[ 'user' ]->getId(), $page->getId() ), + new NamespaceRestriction( $blocks[ 'ip' ]->getId(), NS_USER ), + ] ); + + $this->assertTrue( $block->appliesToUsertalk( $blocks[ 'user' ]->getTarget()->getTalkPage() ) ); + + $this->deleteBlocks( $blocks ); + } + + /** + * @covers ::appliesToRight + * @dataProvider provideTestBlockAppliesToRight + */ + public function testBlockAppliesToRight( $blocks, $right, $expected ) { + $this->setMwGlobals( [ + 'wgBlockDisablesLogin' => false, + ] ); + + $block = new CompositeBlock( [ + 'originalBlocks' => $blocks, + ] ); + + $this->assertSame( $block->appliesToRight( $right ), $expected ); + } + + public static function provideTestBlockAppliesToRight() { + return [ + 'Read is not blocked' => [ + [ + new Block(), + new Block(), + ], + 'read', + false, + ], + 'Email is blocked if blocked by any blocks' => [ + [ + new Block( [ + 'blockEmail' => true, + ] ), + new Block( [ + 'blockEmail' => false, + ] ), + ], + 'sendemail', + true, + ], + ]; + } + + /** + * @covers ::getPermissionsError + * @dataProvider provideGetPermissionsError + */ + public function testGetPermissionsError( $ids, $expectedIdsMsg ) { + // Some block options + $timestamp = time(); + $target = '1.2.3.4'; + $byText = 'MediaWiki default'; + $formattedByText = "\u{202A}{$byText}\u{202C}"; + $reason = ''; + $expiry = 'infinite'; + + $block = $this->getMockBuilder( CompositeBlock::class ) + ->setMethods( [ 'getIds', 'getBlockErrorParams' ] ) + ->getMock(); + $block->method( 'getIds' ) + ->willReturn( $ids ); + $block->method( 'getBlockErrorParams' ) + ->willReturn( [ + $formattedByText, + $reason, + $target, + $formattedByText, + null, + $timestamp, + $target, + $expiry, + ] ); + + $this->assertSame( [ + 'blockedtext-composite', + $formattedByText, + $reason, + $target, + $formattedByText, + $expectedIdsMsg, + $timestamp, + $target, + $expiry, + ], $block->getPermissionsError( RequestContext::getMain() ) ); + } + + public static function provideGetPermissionsError() { + return [ + 'All original blocks are system blocks' => [ + [], + 'Your IP address appears in multiple blacklists', + ], + 'One original block is a database block' => [ + [ 100 ], + 'Relevant block IDs: #100 (your IP address may also be blacklisted)', + ], + 'Several original blocks are database blocks' => [ + [ 100, 101, 102 ], + 'Relevant block IDs: #100, #101, #102 (your IP address may also be blacklisted)', + ], + ]; + } + + /** + * Get an instance of BlockRestrictionStore + * + * @return BlockRestrictionStore + */ + protected function getBlockRestrictionStore() : BlockRestrictionStore { + return MediaWikiServices::getInstance()->getBlockRestrictionStore(); + } +} diff --git a/tests/phpunit/includes/cache/MessageCacheTest.php b/tests/phpunit/includes/cache/MessageCacheTest.php index 2fa662b88b..35dacac598 100644 --- a/tests/phpunit/includes/cache/MessageCacheTest.php +++ b/tests/phpunit/includes/cache/MessageCacheTest.php @@ -204,7 +204,7 @@ class MessageCacheTest extends MediaWikiLangTestCase { ]; } - public function testNoDBAccess() { + public function testNoDBAccessContentLanguage() { global $wgContLanguageCode; $dbr = wfGetDB( DB_REPLICA ); @@ -218,7 +218,22 @@ class MessageCacheTest extends MediaWikiLangTestCase { $dbr->restoreFlags(); - $this->assertEquals( 0, $dbr->trxLevel(), "No DB read queries" ); + $this->assertEquals( 0, $dbr->trxLevel(), "No DB read queries (content language)" ); + } + + public function testNoDBAccessNonContentLanguage() { + $dbr = wfGetDB( DB_REPLICA ); + + MessageCache::singleton()->getMsgFromNamespace( 'allpages/nl', 'nl' ); + + $this->assertEquals( 0, $dbr->trxLevel() ); + $dbr->setFlag( DBO_TRX, $dbr::REMEMBER_PRIOR ); // make queries trigger TRX + + MessageCache::singleton()->getMsgFromNamespace( 'go/nl', 'nl' ); + + $dbr->restoreFlags(); + + $this->assertEquals( 0, $dbr->trxLevel(), "No DB read queries (non-content language)" ); } /** diff --git a/tests/phpunit/includes/changes/ChangesListFilterGroupTest.php b/tests/phpunit/includes/changes/ChangesListFilterGroupTest.php deleted file mode 100644 index 6190516e68..0000000000 --- a/tests/phpunit/includes/changes/ChangesListFilterGroupTest.php +++ /dev/null @@ -1,79 +0,0 @@ - 'some_type', - 'name' => 'group_name', - 'priority' => 1, - 'filters' => [], - ] - ); - } - - public function testAutoPriorities() { - $group = new MockChangesListFilterGroup( - [ - 'type' => 'some_type', - 'name' => 'groupName', - 'isFullCoverage' => true, - 'priority' => 1, - 'filters' => [ - [ 'name' => 'hidefoo' ], - [ 'name' => 'hidebar' ], - [ 'name' => 'hidebaz' ], - ], - ] - ); - - $filters = $group->getFilters(); - $this->assertEquals( - [ - -2, - -3, - -4, - ], - array_map( - function ( $f ) { - return $f->getPriority(); - }, - array_values( $filters ) - ) - ); - } - - // Get without warnings - public function testGetFilter() { - $group = new MockChangesListFilterGroup( - [ - 'type' => 'some_type', - 'name' => 'groupName', - 'isFullCoverage' => true, - 'priority' => 1, - 'filters' => [ - [ 'name' => 'foo' ], - ], - ] - ); - - $this->assertEquals( - 'foo', - $group->getFilter( 'foo' )->getName() - ); - - $this->assertEquals( - null, - $group->getFilter( 'bar' ) - ); - } -} diff --git a/tests/phpunit/includes/composer/ComposerVersionNormalizerTest.php b/tests/phpunit/includes/composer/ComposerVersionNormalizerTest.php deleted file mode 100644 index c5c0dc7d6b..0000000000 --- a/tests/phpunit/includes/composer/ComposerVersionNormalizerTest.php +++ /dev/null @@ -1,163 +0,0 @@ - - */ -class ComposerVersionNormalizerTest extends PHPUnit\Framework\TestCase { - - use MediaWikiCoversValidator; - use PHPUnit4And6Compat; - - /** - * @dataProvider nonStringProvider - */ - public function testGivenNonString_normalizeThrowsInvalidArgumentException( $nonString ) { - $normalizer = new ComposerVersionNormalizer(); - - $this->setExpectedException( InvalidArgumentException::class ); - $normalizer->normalizeSuffix( $nonString ); - } - - public function nonStringProvider() { - return [ - [ null ], - [ 42 ], - [ [] ], - [ new stdClass() ], - [ true ], - ]; - } - - /** - * @dataProvider simpleVersionProvider - */ - public function testGivenSimpleVersion_normalizeSuffixReturnsAsIs( $simpleVersion ) { - $this->assertRemainsUnchanged( $simpleVersion ); - } - - protected function assertRemainsUnchanged( $version ) { - $normalizer = new ComposerVersionNormalizer(); - - $this->assertEquals( - $version, - $normalizer->normalizeSuffix( $version ) - ); - } - - public function simpleVersionProvider() { - return [ - [ '1.22.0' ], - [ '1.19.2' ], - [ '1.19.2.0' ], - [ '1.9' ], - [ '123.321.456.654' ], - ]; - } - - /** - * @dataProvider complexVersionProvider - */ - public function testGivenComplexVersionWithoutDash_normalizeSuffixAddsDash( - $withoutDash, $withDash - ) { - $normalizer = new ComposerVersionNormalizer(); - - $this->assertEquals( - $withDash, - $normalizer->normalizeSuffix( $withoutDash ) - ); - } - - public function complexVersionProvider() { - return [ - [ '1.22.0alpha', '1.22.0-alpha' ], - [ '1.22.0RC', '1.22.0-RC' ], - [ '1.19beta', '1.19-beta' ], - [ '1.9RC4', '1.9-RC4' ], - [ '1.9.1.2RC4', '1.9.1.2-RC4' ], - [ '1.9.1.2RC', '1.9.1.2-RC' ], - [ '123.321.456.654RC9001', '123.321.456.654-RC9001' ], - ]; - } - - /** - * @dataProvider complexVersionProvider - */ - public function testGivenComplexVersionWithDash_normalizeSuffixReturnsAsIs( - $withoutDash, $withDash - ) { - $this->assertRemainsUnchanged( $withDash ); - } - - /** - * @dataProvider fourLevelVersionsProvider - */ - public function testGivenFourLevels_levelCountNormalizationDoesNothing( $version ) { - $normalizer = new ComposerVersionNormalizer(); - - $this->assertEquals( - $version, - $normalizer->normalizeLevelCount( $version ) - ); - } - - public function fourLevelVersionsProvider() { - return [ - [ '1.22.0.0' ], - [ '1.19.2.4' ], - [ '1.19.2.0' ], - [ '1.9.0.1' ], - [ '123.321.456.654' ], - [ '123.321.456.654RC4' ], - [ '123.321.456.654-RC4' ], - ]; - } - - /** - * @dataProvider levelNormalizationProvider - */ - public function testGivenFewerLevels_levelCountNormalizationEnsuresFourLevels( - $expected, $version - ) { - $normalizer = new ComposerVersionNormalizer(); - - $this->assertEquals( - $expected, - $normalizer->normalizeLevelCount( $version ) - ); - } - - public function levelNormalizationProvider() { - return [ - [ '1.22.0.0', '1.22' ], - [ '1.22.0.0', '1.22.0' ], - [ '1.19.2.0', '1.19.2' ], - [ '12345.0.0.0', '12345' ], - [ '12345.0.0.0-RC4', '12345-RC4' ], - [ '12345.0.0.0-alpha', '12345-alpha' ], - ]; - } - - /** - * @dataProvider invalidVersionProvider - */ - public function testGivenInvalidVersion_normalizeSuffixReturnsAsIs( $invalidVersion ) { - $this->assertRemainsUnchanged( $invalidVersion ); - } - - public function invalidVersionProvider() { - return [ - [ '1.221-a' ], - [ '1.221-' ], - [ '1.22rc4a' ], - [ 'a1.22rc' ], - [ '.1.22rc' ], - [ 'a' ], - [ 'alpha42' ], - ]; - } -} diff --git a/tests/phpunit/includes/config/ConfigFactoryTest.php b/tests/phpunit/includes/config/ConfigFactoryTest.php index ea747afac1..f1cc8573b2 100644 --- a/tests/phpunit/includes/config/ConfigFactoryTest.php +++ b/tests/phpunit/includes/config/ConfigFactoryTest.php @@ -2,7 +2,7 @@ use MediaWiki\MediaWikiServices; -class ConfigFactoryTest extends MediaWikiTestCase { +class ConfigFactoryTest extends \MediaWikiIntegrationTestCase { /** * @covers ConfigFactory::register diff --git a/tests/phpunit/includes/config/EtcdConfigTest.php b/tests/phpunit/includes/config/EtcdConfigTest.php deleted file mode 100644 index 3eecf82704..0000000000 --- a/tests/phpunit/includes/config/EtcdConfigTest.php +++ /dev/null @@ -1,621 +0,0 @@ -getMockBuilder( EtcdConfig::class ) - ->setConstructorArgs( [ $options + [ - 'host' => 'etcd-tcp.example.net', - 'directory' => '/', - 'timeout' => 0.1, - ] ] ) - ->setMethods( [ 'fetchAllFromEtcd' ] ) - ->getMock(); - } - - private static function createEtcdResponse( array $response ) { - $baseResponse = [ - 'config' => null, - 'error' => null, - 'retry' => false, - 'modifiedIndex' => 0, - ]; - return array_merge( $baseResponse, $response ); - } - - private function createSimpleConfigMock( array $config, $index = 0 ) { - $mock = $this->createConfigMock(); - $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' ) - ->willReturn( self::createEtcdResponse( [ - 'config' => $config, - 'modifiedIndex' => $index, - ] ) ); - return $mock; - } - - /** - * @covers EtcdConfig::has - */ - public function testHasKnown() { - $config = $this->createSimpleConfigMock( [ - 'known' => 'value' - ] ); - $this->assertSame( true, $config->has( 'known' ) ); - } - - /** - * @covers EtcdConfig::__construct - * @covers EtcdConfig::get - */ - public function testGetKnown() { - $config = $this->createSimpleConfigMock( [ - 'known' => 'value' - ] ); - $this->assertSame( 'value', $config->get( 'known' ) ); - } - - /** - * @covers EtcdConfig::has - */ - public function testHasUnknown() { - $config = $this->createSimpleConfigMock( [ - 'known' => 'value' - ] ); - $this->assertSame( false, $config->has( 'unknown' ) ); - } - - /** - * @covers EtcdConfig::get - */ - public function testGetUnknown() { - $config = $this->createSimpleConfigMock( [ - 'known' => 'value' - ] ); - $this->setExpectedException( ConfigException::class ); - $config->get( 'unknown' ); - } - - /** - * @covers EtcdConfig::getModifiedIndex - */ - public function testGetModifiedIndex() { - $config = $this->createSimpleConfigMock( - [ 'some' => 'value' ], - 123 - ); - $this->assertSame( 123, $config->getModifiedIndex() ); - } - - /** - * @covers EtcdConfig::__construct - */ - public function testConstructCacheObj() { - $cache = $this->getMockBuilder( HashBagOStuff::class ) - ->setMethods( [ 'get' ] ) - ->getMock(); - $cache->expects( $this->once() )->method( 'get' ) - ->willReturn( [ - 'config' => [ 'known' => 'from-cache' ], - 'expires' => INF, - 'modifiedIndex' => 123 - ] ); - $config = $this->createConfigMock( [ 'cache' => $cache ] ); - - $this->assertSame( 'from-cache', $config->get( 'known' ) ); - } - - /** - * @covers EtcdConfig::__construct - */ - public function testConstructCacheSpec() { - $config = $this->createConfigMock( [ 'cache' => [ - 'class' => HashBagOStuff::class - ] ] ); - $config->expects( $this->once() )->method( 'fetchAllFromEtcd' ) - ->willReturn( self::createEtcdResponse( - [ 'config' => [ 'known' => 'from-fetch' ], ] ) ); - - $this->assertSame( 'from-fetch', $config->get( 'known' ) ); - } - - /** - * Test matrix - * - * - [x] Cache miss - * Result: Fetched value - * > cache miss | gets lock | backend succeeds - * - * - [x] Cache miss with backend error - * Result: ConfigException - * > cache miss | gets lock | backend error (no retry) - * - * - [x] Cache hit after retry - * Result: Cached value (populated by process holding lock) - * > cache miss | no lock | cache retry - * - * - [x] Cache hit - * Result: Cached value - * > cache hit - * - * - [x] Process cache hit - * Result: Cached value - * > process cache hit - * - * - [x] Cache expired - * Result: Fetched value - * > cache expired | gets lock | backend succeeds - * - * - [x] Cache expired with backend failure - * Result: Cached value (stale) - * > cache expired | gets lock | backend fails (allows retry) - * - * - [x] Cache expired and no lock - * Result: Cached value (stale) - * > cache expired | no lock - * - * Other notable scenarios: - * - * - [ ] Cache miss with backend retry - * Result: Fetched value - * > cache expired | gets lock | backend failure (allows retry) - */ - - /** - * @covers EtcdConfig::load - */ - public function testLoadCacheMiss() { - // Create cache mock - $cache = $this->getMockBuilder( HashBagOStuff::class ) - ->setMethods( [ 'get', 'lock' ] ) - ->getMock(); - // .. misses cache - $cache->expects( $this->once() )->method( 'get' ) - ->willReturn( false ); - // .. gets lock - $cache->expects( $this->once() )->method( 'lock' ) - ->willReturn( true ); - - // Create config mock - $mock = $this->createConfigMock( [ - 'cache' => $cache, - ] ); - $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' ) - ->willReturn( - self::createEtcdResponse( [ 'config' => [ 'known' => 'from-fetch' ] ] ) ); - - $this->assertSame( 'from-fetch', $mock->get( 'known' ) ); - } - - /** - * @covers EtcdConfig::load - */ - public function testLoadCacheMissBackendError() { - // Create cache mock - $cache = $this->getMockBuilder( HashBagOStuff::class ) - ->setMethods( [ 'get', 'lock' ] ) - ->getMock(); - // .. misses cache - $cache->expects( $this->once() )->method( 'get' ) - ->willReturn( false ); - // .. gets lock - $cache->expects( $this->once() )->method( 'lock' ) - ->willReturn( true ); - - // Create config mock - $mock = $this->createConfigMock( [ - 'cache' => $cache, - ] ); - $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' ) - ->willReturn( self::createEtcdResponse( [ 'error' => 'Fake error', ] ) ); - - $this->setExpectedException( ConfigException::class ); - $mock->get( 'key' ); - } - - /** - * @covers EtcdConfig::load - */ - public function testLoadCacheMissWithoutLock() { - // Create cache mock - $cache = $this->getMockBuilder( HashBagOStuff::class ) - ->setMethods( [ 'get', 'lock' ] ) - ->getMock(); - $cache->expects( $this->exactly( 2 ) )->method( 'get' ) - ->will( $this->onConsecutiveCalls( - // .. misses cache first time - false, - // .. hits cache on retry - [ - 'config' => [ 'known' => 'from-cache' ], - 'expires' => INF, - 'modifiedIndex' => 123 - ] - ) ); - // .. misses lock - $cache->expects( $this->once() )->method( 'lock' ) - ->willReturn( false ); - - // Create config mock - $mock = $this->createConfigMock( [ - 'cache' => $cache, - ] ); - $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' ); - - $this->assertSame( 'from-cache', $mock->get( 'known' ) ); - } - - /** - * @covers EtcdConfig::load - */ - public function testLoadCacheHit() { - // Create cache mock - $cache = $this->getMockBuilder( HashBagOStuff::class ) - ->setMethods( [ 'get', 'lock' ] ) - ->getMock(); - $cache->expects( $this->once() )->method( 'get' ) - // .. hits cache - ->willReturn( [ - 'config' => [ 'known' => 'from-cache' ], - 'expires' => INF, - 'modifiedIndex' => 0, - ] ); - $cache->expects( $this->never() )->method( 'lock' ); - - // Create config mock - $mock = $this->createConfigMock( [ - 'cache' => $cache, - ] ); - $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' ); - - $this->assertSame( 'from-cache', $mock->get( 'known' ) ); - } - - /** - * @covers EtcdConfig::load - */ - public function testLoadProcessCacheHit() { - // Create cache mock - $cache = $this->getMockBuilder( HashBagOStuff::class ) - ->setMethods( [ 'get', 'lock' ] ) - ->getMock(); - $cache->expects( $this->once() )->method( 'get' ) - // .. hits cache - ->willReturn( [ - 'config' => [ 'known' => 'from-cache' ], - 'expires' => INF, - 'modifiedIndex' => 0, - ] ); - $cache->expects( $this->never() )->method( 'lock' ); - - // Create config mock - $mock = $this->createConfigMock( [ - 'cache' => $cache, - ] ); - $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' ); - - $this->assertSame( 'from-cache', $mock->get( 'known' ), 'Cache hit' ); - $this->assertSame( 'from-cache', $mock->get( 'known' ), 'Process cache hit' ); - } - - /** - * @covers EtcdConfig::load - */ - public function testLoadCacheExpiredLockFetchSucceeded() { - // Create cache mock - $cache = $this->getMockBuilder( HashBagOStuff::class ) - ->setMethods( [ 'get', 'lock' ] ) - ->getMock(); - $cache->expects( $this->once() )->method( 'get' )->willReturn( - // .. stale cache - [ - 'config' => [ 'known' => 'from-cache-expired' ], - 'expires' => -INF, - 'modifiedIndex' => 0, - ] - ); - // .. gets lock - $cache->expects( $this->once() )->method( 'lock' ) - ->willReturn( true ); - - // Create config mock - $mock = $this->createConfigMock( [ - 'cache' => $cache, - ] ); - $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' ) - ->willReturn( self::createEtcdResponse( [ 'config' => [ 'known' => 'from-fetch' ] ] ) ); - - $this->assertSame( 'from-fetch', $mock->get( 'known' ) ); - } - - /** - * @covers EtcdConfig::load - */ - public function testLoadCacheExpiredLockFetchFails() { - // Create cache mock - $cache = $this->getMockBuilder( HashBagOStuff::class ) - ->setMethods( [ 'get', 'lock' ] ) - ->getMock(); - $cache->expects( $this->once() )->method( 'get' )->willReturn( - // .. stale cache - [ - 'config' => [ 'known' => 'from-cache-expired' ], - 'expires' => -INF, - 'modifiedIndex' => 0, - ] - ); - // .. gets lock - $cache->expects( $this->once() )->method( 'lock' ) - ->willReturn( true ); - - // Create config mock - $mock = $this->createConfigMock( [ - 'cache' => $cache, - ] ); - $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' ) - ->willReturn( self::createEtcdResponse( [ 'error' => 'Fake failure', 'retry' => true ] ) ); - - $this->assertSame( 'from-cache-expired', $mock->get( 'known' ) ); - } - - /** - * @covers EtcdConfig::load - */ - public function testLoadCacheExpiredNoLock() { - // Create cache mock - $cache = $this->getMockBuilder( HashBagOStuff::class ) - ->setMethods( [ 'get', 'lock' ] ) - ->getMock(); - $cache->expects( $this->once() )->method( 'get' ) - // .. hits cache (expired value) - ->willReturn( [ - 'config' => [ 'known' => 'from-cache-expired' ], - 'expires' => -INF, - 'modifiedIndex' => 0, - ] ); - // .. misses lock - $cache->expects( $this->once() )->method( 'lock' ) - ->willReturn( false ); - - // Create config mock - $mock = $this->createConfigMock( [ - 'cache' => $cache, - ] ); - $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' ); - - $this->assertSame( 'from-cache-expired', $mock->get( 'known' ) ); - } - - public static function provideFetchFromServer() { - return [ - '200 OK - Success' => [ - 'http' => [ - 'code' => 200, - 'reason' => 'OK', - 'headers' => [], - 'body' => json_encode( [ 'node' => [ 'nodes' => [ - [ - 'key' => '/example/foo', - 'value' => json_encode( [ 'val' => true ] ), - 'modifiedIndex' => 123 - ], - ] ] ] ), - 'error' => '', - ], - 'expect' => self::createEtcdResponse( [ - 'config' => [ 'foo' => true ], // data - 'modifiedIndex' => 123 - ] ), - ], - '200 OK - Empty dir' => [ - 'http' => [ - 'code' => 200, - 'reason' => 'OK', - 'headers' => [], - 'body' => json_encode( [ 'node' => [ 'nodes' => [ - [ - 'key' => '/example/foo', - 'value' => json_encode( [ 'val' => true ] ), - 'modifiedIndex' => 123 - ], - [ - 'key' => '/example/sub', - 'dir' => true, - 'modifiedIndex' => 234, - 'nodes' => [], - ], - [ - 'key' => '/example/bar', - 'value' => json_encode( [ 'val' => false ] ), - 'modifiedIndex' => 125 - ], - ] ] ] ), - 'error' => '', - ], - 'expect' => self::createEtcdResponse( [ - 'config' => [ 'foo' => true, 'bar' => false ], // data - 'modifiedIndex' => 125 // largest modified index - ] ), - ], - '200 OK - Recursive' => [ - 'http' => [ - 'code' => 200, - 'reason' => 'OK', - 'headers' => [], - 'body' => json_encode( [ 'node' => [ 'nodes' => [ - [ - 'key' => '/example/a', - 'dir' => true, - 'modifiedIndex' => 124, - 'nodes' => [ - [ - 'key' => 'b', - 'value' => json_encode( [ 'val' => true ] ), - 'modifiedIndex' => 123, - - ], - [ - 'key' => 'c', - 'value' => json_encode( [ 'val' => false ] ), - 'modifiedIndex' => 123, - ], - ], - ], - ] ] ] ), - 'error' => '', - ], - 'expect' => self::createEtcdResponse( [ - 'config' => [ 'a/b' => true, 'a/c' => false ], // data - 'modifiedIndex' => 123 // largest modified index - ] ), - ], - '200 OK - Missing nodes at second level' => [ - 'http' => [ - 'code' => 200, - 'reason' => 'OK', - 'headers' => [], - 'body' => json_encode( [ 'node' => [ 'nodes' => [ - [ - 'key' => '/example/a', - 'dir' => true, - 'modifiedIndex' => 0, - ], - ] ] ] ), - 'error' => '', - ], - 'expect' => self::createEtcdResponse( [ - 'error' => "Unexpected JSON response in dir 'a'; missing 'nodes' list.", - ] ), - ], - '200 OK - Directory with non-array "nodes" key' => [ - 'http' => [ - 'code' => 200, - 'reason' => 'OK', - 'headers' => [], - 'body' => json_encode( [ 'node' => [ 'nodes' => [ - [ - 'key' => '/example/a', - 'dir' => true, - 'nodes' => 'not an array' - ], - ] ] ] ), - 'error' => '', - ], - 'expect' => self::createEtcdResponse( [ - 'error' => "Unexpected JSON response in dir 'a'; 'nodes' is not an array.", - ] ), - ], - '200 OK - Correctly encoded garbage response' => [ - 'http' => [ - 'code' => 200, - 'reason' => 'OK', - 'headers' => [], - 'body' => json_encode( [ 'foo' => 'bar' ] ), - 'error' => '', - ], - 'expect' => self::createEtcdResponse( [ - 'error' => "Unexpected JSON response: Missing or invalid node at top level.", - ] ), - ], - '200 OK - Bad value' => [ - 'http' => [ - 'code' => 200, - 'reason' => 'OK', - 'headers' => [], - 'body' => json_encode( [ 'node' => [ 'nodes' => [ - [ - 'key' => '/example/foo', - 'value' => ';"broken{value', - 'modifiedIndex' => 123, - ] - ] ] ] ), - 'error' => '', - ], - 'expect' => self::createEtcdResponse( [ - 'error' => "Failed to parse value for 'foo'.", - ] ), - ], - '200 OK - Empty node list' => [ - 'http' => [ - 'code' => 200, - 'reason' => 'OK', - 'headers' => [], - 'body' => '{"node":{"nodes":[], "modifiedIndex": 12 }}', - 'error' => '', - ], - 'expect' => self::createEtcdResponse( [ - 'config' => [], // data - ] ), - ], - '200 OK - Invalid JSON' => [ - 'http' => [ - 'code' => 200, - 'reason' => 'OK', - 'headers' => [ 'content-length' => 0 ], - 'body' => '', - 'error' => '(curl error: no status set)', - ], - 'expect' => self::createEtcdResponse( [ - 'error' => "Error unserializing JSON response.", - ] ), - ], - '404 Not Found' => [ - 'http' => [ - 'code' => 404, - 'reason' => 'Not Found', - 'headers' => [ 'content-length' => 0 ], - 'body' => '', - 'error' => '', - ], - 'expect' => self::createEtcdResponse( [ - 'error' => 'HTTP 404 (Not Found)', - ] ), - ], - '400 Bad Request - custom error' => [ - 'http' => [ - 'code' => 400, - 'reason' => 'Bad Request', - 'headers' => [ 'content-length' => 0 ], - 'body' => '', - 'error' => 'No good reason', - ], - 'expect' => self::createEtcdResponse( [ - 'error' => 'No good reason', - 'retry' => true, // retry - ] ), - ], - ]; - } - - /** - * @covers EtcdConfig::fetchAllFromEtcdServer - * @covers EtcdConfig::unserialize - * @covers EtcdConfig::parseResponse - * @covers EtcdConfig::parseDirectory - * @covers EtcdConfigParseError - * @dataProvider provideFetchFromServer - */ - public function testFetchFromServer( array $httpResponse, array $expected ) { - $http = $this->getMockBuilder( MultiHttpClient::class ) - ->disableOriginalConstructor() - ->getMock(); - $http->expects( $this->once() )->method( 'run' ) - ->willReturn( array_values( $httpResponse ) ); - - $conf = $this->getMockBuilder( EtcdConfig::class ) - ->disableOriginalConstructor() - ->getMock(); - // Access for protected member and method - $conf = TestingAccessWrapper::newFromObject( $conf ); - $conf->http = $http; - - $this->assertSame( - $expected, - $conf->fetchAllFromEtcdServer( 'etcd-tcp.example.net' ) - ); - } -} diff --git a/tests/phpunit/includes/config/HashConfigTest.php b/tests/phpunit/includes/config/HashConfigTest.php deleted file mode 100644 index bac8311cd4..0000000000 --- a/tests/phpunit/includes/config/HashConfigTest.php +++ /dev/null @@ -1,63 +0,0 @@ -assertInstanceOf( HashConfig::class, $conf ); - } - - /** - * @covers HashConfig::__construct - */ - public function testConstructor() { - $conf = new HashConfig(); - $this->assertInstanceOf( HashConfig::class, $conf ); - - // Test passing arguments to the constructor - $conf2 = new HashConfig( [ - 'one' => '1', - ] ); - $this->assertEquals( '1', $conf2->get( 'one' ) ); - } - - /** - * @covers HashConfig::get - */ - public function testGet() { - $conf = new HashConfig( [ - 'one' => '1', - ] ); - $this->assertEquals( '1', $conf->get( 'one' ) ); - $this->setExpectedException( ConfigException::class, 'HashConfig::get: undefined option' ); - $conf->get( 'two' ); - } - - /** - * @covers HashConfig::has - */ - public function testHas() { - $conf = new HashConfig( [ - 'one' => '1', - ] ); - $this->assertTrue( $conf->has( 'one' ) ); - $this->assertFalse( $conf->has( 'two' ) ); - } - - /** - * @covers HashConfig::set - */ - public function testSet() { - $conf = new HashConfig( [ - 'one' => '1', - ] ); - $conf->set( 'two', '2' ); - $this->assertEquals( '2', $conf->get( 'two' ) ); - // Check that set overwrites - $conf->set( 'one', '3' ); - $this->assertEquals( '3', $conf->get( 'one' ) ); - } -} diff --git a/tests/phpunit/includes/config/MultiConfigTest.php b/tests/phpunit/includes/config/MultiConfigTest.php deleted file mode 100644 index fc2839513b..0000000000 --- a/tests/phpunit/includes/config/MultiConfigTest.php +++ /dev/null @@ -1,39 +0,0 @@ - 'bar' ] ), - new HashConfig( [ 'foo' => 'baz', 'bar' => 'foo' ] ), - new HashConfig( [ 'bar' => 'baz' ] ), - ] ); - - $this->assertEquals( 'bar', $multi->get( 'foo' ) ); - $this->assertEquals( 'foo', $multi->get( 'bar' ) ); - $this->setExpectedException( ConfigException::class, 'MultiConfig::get: undefined option:' ); - $multi->get( 'notset' ); - } - - /** - * @covers MultiConfig::has - */ - public function testHas() { - $conf = new MultiConfig( [ - new HashConfig( [ 'foo' => 'foo' ] ), - new HashConfig( [ 'something' => 'bleh' ] ), - new HashConfig( [ 'meh' => 'eh' ] ), - ] ); - - $this->assertTrue( $conf->has( 'foo' ) ); - $this->assertTrue( $conf->has( 'something' ) ); - $this->assertTrue( $conf->has( 'meh' ) ); - $this->assertFalse( $conf->has( 'what' ) ); - } -} diff --git a/tests/phpunit/includes/config/ServiceOptionsTest.php b/tests/phpunit/includes/config/ServiceOptionsTest.php deleted file mode 100644 index 966cf411c7..0000000000 --- a/tests/phpunit/includes/config/ServiceOptionsTest.php +++ /dev/null @@ -1,149 +0,0 @@ - $val ) { - $this->assertSame( $val, $options->get( $key ) ); - } - - // This is lumped in the same test because there's no support for depending on a test that - // has a data provider. - $options->assertRequiredOptions( array_keys( $expected ) ); - - // Suppress warning if no assertions were run. This is expected for empty arguments. - $this->assertTrue( true ); - } - - public function provideConstructor() { - return [ - 'No keys' => [ [], [], [ 'a' => 'aval' ] ], - 'Simple array source' => [ - [ 'a' => 'aval', 'b' => 'bval' ], - [ 'a', 'b' ], - [ 'a' => 'aval', 'b' => 'bval', 'c' => 'cval' ], - ], - 'Simple HashConfig source' => [ - [ 'a' => 'aval', 'b' => 'bval' ], - [ 'a', 'b' ], - new HashConfig( [ 'a' => 'aval', 'b' => 'bval', 'c' => 'cval' ] ), - ], - 'Three different sources' => [ - [ 'a' => 'aval', 'b' => 'bval' ], - [ 'a', 'b' ], - [ 'z' => 'zval' ], - new HashConfig( [ 'a' => 'aval', 'c' => 'cval' ] ), - [ 'b' => 'bval', 'd' => 'dval' ], - ], - 'null key' => [ - [ 'a' => null ], - [ 'a' ], - [ 'a' => null ], - ], - 'Numeric option name' => [ - [ '0' => 'nothing' ], - [ '0' ], - [ '0' => 'nothing' ], - ], - 'Multiple sources for one key' => [ - [ 'a' => 'winner' ], - [ 'a' ], - [ 'a' => 'winner' ], - [ 'a' => 'second place' ], - ], - 'Object value is passed by reference' => [ - [ 'a' => self::$testObj ], - [ 'a' ], - [ 'a' => self::$testObj ], - ], - ]; - } - - /** - * @covers ::__construct - */ - public function testKeyNotFound() { - $this->setExpectedException( InvalidArgumentException::class, - 'Key "a" not found in input sources' ); - - new ServiceOptions( [ 'a' ], [ 'b' => 'bval' ], [ 'c' => 'cval' ] ); - } - - /** - * @covers ::__construct - * @covers ::assertRequiredOptions - */ - public function testOutOfOrderAssertRequiredOptions() { - $options = new ServiceOptions( [ 'a', 'b' ], [ 'a' => '', 'b' => '' ] ); - $options->assertRequiredOptions( [ 'b', 'a' ] ); - $this->assertTrue( true, 'No exception thrown' ); - } - - /** - * @covers ::__construct - * @covers ::get - */ - public function testGetUnrecognized() { - $this->setExpectedException( InvalidArgumentException::class, - 'Unrecognized option "b"' ); - - $options = new ServiceOptions( [ 'a' ], [ 'a' => '' ] ); - $options->get( 'b' ); - } - - /** - * @covers ::__construct - * @covers ::assertRequiredOptions - */ - public function testExtraKeys() { - $this->setExpectedException( Wikimedia\Assert\PreconditionException::class, - 'Precondition failed: Unsupported options passed: b, c!' ); - - $options = new ServiceOptions( [ 'a', 'b', 'c' ], [ 'a' => '', 'b' => '', 'c' => '' ] ); - $options->assertRequiredOptions( [ 'a' ] ); - } - - /** - * @covers ::__construct - * @covers ::assertRequiredOptions - */ - public function testMissingKeys() { - $this->setExpectedException( Wikimedia\Assert\PreconditionException::class, - 'Precondition failed: Required options missing: a, b!' ); - - $options = new ServiceOptions( [ 'c' ], [ 'c' => '' ] ); - $options->assertRequiredOptions( [ 'a', 'b', 'c' ] ); - } - - /** - * @covers ::__construct - * @covers ::assertRequiredOptions - */ - public function testExtraAndMissingKeys() { - $this->setExpectedException( Wikimedia\Assert\PreconditionException::class, - 'Precondition failed: Unsupported options passed: b! Required options missing: c!' ); - - $options = new ServiceOptions( [ 'a', 'b' ], [ 'a' => '', 'b' => '' ] ); - $options->assertRequiredOptions( [ 'a', 'c' ] ); - } -} diff --git a/tests/phpunit/includes/content/JsonContentHandlerTest.php b/tests/phpunit/includes/content/JsonContentHandlerTest.php deleted file mode 100644 index abfb6733a5..0000000000 --- a/tests/phpunit/includes/content/JsonContentHandlerTest.php +++ /dev/null @@ -1,14 +0,0 @@ -makeEmptyContent(); - $this->assertInstanceOf( JsonContent::class, $content ); - $this->assertTrue( $content->isValid() ); - } -} diff --git a/tests/phpunit/includes/db/DatabaseOracleTest.php b/tests/phpunit/includes/db/DatabaseOracleTest.php deleted file mode 100644 index 061e121a24..0000000000 --- a/tests/phpunit/includes/db/DatabaseOracleTest.php +++ /dev/null @@ -1,52 +0,0 @@ -getMockBuilder( DatabaseOracle::class ) - ->disableOriginalConstructor() - ->setMethods( null ) - ->getMock(); - } - - public function provideBuildSubstring() { - yield [ 'someField', 1, 2, 'SUBSTR(someField,1,2)' ]; - yield [ 'someField', 1, null, 'SUBSTR(someField,1)' ]; - } - - /** - * @covers DatabaseOracle::buildSubstring - * @dataProvider provideBuildSubstring - */ - public function testBuildSubstring( $input, $start, $length, $expected ) { - $mockDb = $this->getMockDb(); - $output = $mockDb->buildSubstring( $input, $start, $length ); - $this->assertSame( $expected, $output ); - } - - public function provideBuildSubstring_invalidParams() { - yield [ -1, 1 ]; - yield [ 1, -1 ]; - yield [ 1, 'foo' ]; - yield [ 'foo', 1 ]; - yield [ null, 1 ]; - yield [ 0, 1 ]; - } - - /** - * @covers DatabaseOracle::buildSubstring - * @dataProvider provideBuildSubstring_invalidParams - */ - public function testBuildSubstring_invalidParams( $start, $length ) { - $mockDb = $this->getMockDb(); - $this->setExpectedException( InvalidArgumentException::class ); - $mockDb->buildSubstring( 'foo', $start, $length ); - } - -} diff --git a/tests/phpunit/includes/db/DatabaseSqliteTest.php b/tests/phpunit/includes/db/DatabaseSqliteTest.php deleted file mode 100644 index 857988c899..0000000000 --- a/tests/phpunit/includes/db/DatabaseSqliteTest.php +++ /dev/null @@ -1,553 +0,0 @@ -markTestSkipped( 'No SQLite support detected' ); - } - $this->db = $this->getMockBuilder( DatabaseSqlite::class ) - ->setConstructorArgs( [ [ - 'dbFilePath' => ':memory:', - 'schema' => false, - 'host' => false, - 'user' => false, - 'password' => false, - 'tablePrefix' => '', - 'cliMode' => true, - 'agent' => 'unit-tests', - 'flags' => DBO_DEFAULT, - 'variables' => [], - 'profiler' => null, - 'trxProfiler' => new TransactionProfiler(), - 'connLogger' => new NullLogger(), - 'queryLogger' => new NullLogger(), - 'errorLogger' => null, - 'deprecationLogger' => null, - ] ] )->setMethods( [ 'query' ] ) - ->getMock(); - $this->db->initConnection(); - $this->db->method( 'query' )->willReturn( true ); - if ( version_compare( $this->db->getServerVersion(), '3.6.0', '<' ) ) { - $this->markTestSkipped( "SQLite at least 3.6 required, {$this->db->getServerVersion()} found" ); - } - } - - /** - * @param $sql - * @return string|string[]|null - */ - private function replaceVars( $sql ) { - $wrapper = TestingAccessWrapper::newFromObject( $this->db ); - // normalize spacing to hide implementation details - return preg_replace( '/\s+/', ' ', $wrapper->replaceVars( $sql ) ); - } - - private function assertResultIs( $expected, $res ) { - $this->assertNotNull( $res ); - $i = 0; - foreach ( $res as $row ) { - foreach ( $expected[$i] as $key => $value ) { - $this->assertTrue( isset( $row->$key ) ); - $this->assertEquals( $value, $row->$key ); - } - $i++; - } - $this->assertEquals( count( $expected ), $i, 'Unexpected number of rows' ); - } - - public static function provideAddQuotes() { - return [ - [ // #0: empty - '', "''" - ], - [ // #1: simple - 'foo bar', "'foo bar'" - ], - [ // #2: including quote - 'foo\'bar', "'foo''bar'" - ], - // #3: including \0 (must be represented as hex, per https://bugs.php.net/bug.php?id=63419) - [ - "x\0y", - "x'780079'", - ], - [ // #4: blob object (must be represented as hex) - new Blob( "hello" ), - "x'68656c6c6f'", - ], - [ // #5: null - null, - "''", - ], - ]; - } - - /** - * @dataProvider provideAddQuotes() - * @covers DatabaseSqlite::addQuotes - */ - public function testAddQuotes( $value, $expected ) { - // check quoting - $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); - $this->assertEquals( $expected, $db->addQuotes( $value ), 'string not quoted as expected' ); - - // ok, quoting works as expected, now try a round trip. - $re = $db->query( 'select ' . $db->addQuotes( $value ) ); - - $this->assertTrue( $re !== false, 'query failed' ); - - $row = $re->fetchRow(); - if ( $row ) { - if ( $value instanceof Blob ) { - $value = $value->fetch(); - } - - $this->assertEquals( $value, $row[0], 'string mangled by the database' ); - } else { - $this->fail( 'query returned no result' ); - } - } - - /** - * @covers DatabaseSqlite::replaceVars - */ - public function testReplaceVars() { - $this->assertEquals( 'foo', $this->replaceVars( 'foo' ), "Don't break anything accidentally" ); - - $this->assertEquals( - "CREATE TABLE /**/foo (foo_key INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, " - . "foo_bar TEXT, foo_name TEXT NOT NULL DEFAULT '', foo_int INTEGER, foo_int2 INTEGER );", - $this->replaceVars( - "CREATE TABLE /**/foo (foo_key int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT, " - . "foo_bar char(13), foo_name varchar(255) binary NOT NULL DEFAULT '', " - . "foo_int tinyint ( 8 ), foo_int2 int(16) ) ENGINE=MyISAM;" - ) - ); - - $this->assertEquals( - "CREATE TABLE foo ( foo1 REAL, foo2 REAL, foo3 REAL );", - $this->replaceVars( - "CREATE TABLE foo ( foo1 FLOAT, foo2 DOUBLE( 1,10), foo3 DOUBLE PRECISION );" - ) - ); - - $this->assertEquals( "CREATE TABLE foo ( foo_binary1 BLOB, foo_binary2 BLOB );", - $this->replaceVars( "CREATE TABLE foo ( foo_binary1 binary(16), foo_binary2 varbinary(32) );" ) - ); - - $this->assertEquals( "CREATE TABLE text ( text_foo TEXT );", - $this->replaceVars( "CREATE TABLE text ( text_foo tinytext );" ), - 'Table name changed' - ); - - $this->assertEquals( "CREATE TABLE foo ( foobar INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL );", - $this->replaceVars( "CREATE TABLE foo ( foobar INT PRIMARY KEY NOT NULL AUTO_INCREMENT );" ) - ); - $this->assertEquals( "CREATE TABLE foo ( foobar INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL );", - $this->replaceVars( "CREATE TABLE foo ( foobar INT PRIMARY KEY AUTO_INCREMENT NOT NULL );" ) - ); - - $this->assertEquals( "CREATE TABLE enums( enum1 TEXT, myenum TEXT)", - $this->replaceVars( "CREATE TABLE enums( enum1 ENUM('A', 'B'), myenum ENUM ('X', 'Y'))" ) - ); - - $this->assertEquals( "ALTER TABLE foo ADD COLUMN foo_bar INTEGER DEFAULT 42", - $this->replaceVars( "ALTER TABLE foo\nADD COLUMN foo_bar int(10) unsigned DEFAULT 42" ) - ); - - $this->assertEquals( "DROP INDEX foo", - $this->replaceVars( "DROP INDEX /*i*/foo ON /*_*/bar" ) - ); - - $this->assertEquals( "DROP INDEX foo -- dropping index", - $this->replaceVars( "DROP INDEX /*i*/foo ON /*_*/bar -- dropping index" ) - ); - $this->assertEquals( "INSERT OR IGNORE INTO foo VALUES ('bar')", - $this->replaceVars( "INSERT OR IGNORE INTO foo VALUES ('bar')" ) - ); - } - - /** - * @covers DatabaseSqlite::tableName - */ - public function testTableName() { - // @todo Moar! - $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); - $this->assertEquals( 'foo', $db->tableName( 'foo' ) ); - $this->assertEquals( 'sqlite_master', $db->tableName( 'sqlite_master' ) ); - $db->tablePrefix( 'foo_' ); - $this->assertEquals( 'sqlite_master', $db->tableName( 'sqlite_master' ) ); - $this->assertEquals( 'foo_bar', $db->tableName( 'bar' ) ); - } - - /** - * @covers DatabaseSqlite::duplicateTableStructure - */ - public function testDuplicateTableStructure() { - $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); - $db->query( 'CREATE TABLE foo(foo, barfoo)' ); - $db->query( 'CREATE INDEX index1 ON foo(foo)' ); - $db->query( 'CREATE UNIQUE INDEX index2 ON foo(barfoo)' ); - - $db->duplicateTableStructure( 'foo', 'bar' ); - $this->assertEquals( 'CREATE TABLE "bar"(foo, barfoo)', - $db->selectField( 'sqlite_master', 'sql', [ 'name' => 'bar' ] ), - 'Normal table duplication' - ); - $indexList = $db->query( 'PRAGMA INDEX_LIST("bar")' ); - $index = $indexList->next(); - $this->assertEquals( 'bar_index1', $index->name ); - $this->assertEquals( '0', $index->unique ); - $index = $indexList->next(); - $this->assertEquals( 'bar_index2', $index->name ); - $this->assertEquals( '1', $index->unique ); - - $db->duplicateTableStructure( 'foo', 'baz', true ); - $this->assertEquals( 'CREATE TABLE "baz"(foo, barfoo)', - $db->selectField( 'sqlite_temp_master', 'sql', [ 'name' => 'baz' ] ), - 'Creation of temporary duplicate' - ); - $indexList = $db->query( 'PRAGMA INDEX_LIST("baz")' ); - $index = $indexList->next(); - $this->assertEquals( 'baz_index1', $index->name ); - $this->assertEquals( '0', $index->unique ); - $index = $indexList->next(); - $this->assertEquals( 'baz_index2', $index->name ); - $this->assertEquals( '1', $index->unique ); - $this->assertEquals( 0, - $db->selectField( 'sqlite_master', 'COUNT(*)', [ 'name' => 'baz' ] ), - 'Create a temporary duplicate only' - ); - } - - /** - * @covers DatabaseSqlite::duplicateTableStructure - */ - public function testDuplicateTableStructureVirtual() { - $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); - if ( $db->getFulltextSearchModule() != 'FTS3' ) { - $this->markTestSkipped( 'FTS3 not supported, cannot create virtual tables' ); - } - $db->query( 'CREATE VIRTUAL TABLE "foo" USING FTS3(foobar)' ); - - $db->duplicateTableStructure( 'foo', 'bar' ); - $this->assertEquals( 'CREATE VIRTUAL TABLE "bar" USING FTS3(foobar)', - $db->selectField( 'sqlite_master', 'sql', [ 'name' => 'bar' ] ), - 'Duplication of virtual tables' - ); - - $db->duplicateTableStructure( 'foo', 'baz', true ); - $this->assertEquals( 'CREATE VIRTUAL TABLE "baz" USING FTS3(foobar)', - $db->selectField( 'sqlite_master', 'sql', [ 'name' => 'baz' ] ), - "Can't create temporary virtual tables, should fall back to non-temporary duplication" - ); - } - - /** - * @covers DatabaseSqlite::deleteJoin - */ - public function testDeleteJoin() { - $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); - $db->query( 'CREATE TABLE a (a_1)', __METHOD__ ); - $db->query( 'CREATE TABLE b (b_1, b_2)', __METHOD__ ); - $db->insert( 'a', [ - [ 'a_1' => 1 ], - [ 'a_1' => 2 ], - [ 'a_1' => 3 ], - ], - __METHOD__ - ); - $db->insert( 'b', [ - [ 'b_1' => 2, 'b_2' => 'a' ], - [ 'b_1' => 3, 'b_2' => 'b' ], - ], - __METHOD__ - ); - $db->deleteJoin( 'a', 'b', 'a_1', 'b_1', [ 'b_2' => 'a' ], __METHOD__ ); - $res = $db->query( "SELECT * FROM a", __METHOD__ ); - $this->assertResultIs( [ - [ 'a_1' => 1 ], - [ 'a_1' => 3 ], - ], - $res - ); - } - - /** - * @coversNothing - */ - public function testEntireSchema() { - global $IP; - - $result = Sqlite::checkSqlSyntax( "$IP/maintenance/tables.sql" ); - if ( $result !== true ) { - $this->fail( $result ); - } - $this->assertTrue( true ); // avoid test being marked as incomplete due to lack of assertions - } - - /** - * Runs upgrades of older databases and compares results with current schema - * @todo Currently only checks list of tables - * @coversNothing - */ - public function testUpgrades() { - global $IP, $wgVersion, $wgProfiler; - - // Versions tested - $versions = [ - // '1.13', disabled for now, was totally screwed up - // SQLite wasn't included in 1.14 - '1.15', - '1.16', - '1.17', - '1.18', - '1.19', - '1.20', - '1.21', - '1.22', - '1.23', - ]; - - // Mismatches for these columns we can safely ignore - $ignoredColumns = [ - 'user_newtalk.user_last_timestamp', // r84185 - ]; - - $currentDB = DatabaseSqlite::newStandaloneInstance( ':memory:' ); - $currentDB->sourceFile( "$IP/maintenance/tables.sql" ); - - $profileToDb = false; - if ( isset( $wgProfiler['output'] ) ) { - $out = $wgProfiler['output']; - if ( $out === 'db' ) { - $profileToDb = true; - } elseif ( is_array( $out ) && in_array( 'db', $out ) ) { - $profileToDb = true; - } - } - - if ( $profileToDb ) { - $currentDB->sourceFile( "$IP/maintenance/sqlite/archives/patch-profiling.sql" ); - } - $currentTables = $this->getTables( $currentDB ); - sort( $currentTables ); - - foreach ( $versions as $version ) { - $versions = "upgrading from $version to $wgVersion"; - $db = $this->prepareTestDB( $version ); - $tables = $this->getTables( $db ); - $this->assertEquals( $currentTables, $tables, "Different tables $versions" ); - foreach ( $tables as $table ) { - $currentCols = $this->getColumns( $currentDB, $table ); - $cols = $this->getColumns( $db, $table ); - $this->assertEquals( - array_keys( $currentCols ), - array_keys( $cols ), - "Mismatching columns for table \"$table\" $versions" - ); - foreach ( $currentCols as $name => $column ) { - $fullName = "$table.$name"; - $this->assertEquals( - (bool)$column->pk, - (bool)$cols[$name]->pk, - "PRIMARY KEY status does not match for column $fullName $versions" - ); - if ( !in_array( $fullName, $ignoredColumns ) ) { - $this->assertEquals( - (bool)$column->notnull, - (bool)$cols[$name]->notnull, - "NOT NULL status does not match for column $fullName $versions" - ); - $this->assertEquals( - $column->dflt_value, - $cols[$name]->dflt_value, - "Default values does not match for column $fullName $versions" - ); - } - } - $currentIndexes = $this->getIndexes( $currentDB, $table ); - $indexes = $this->getIndexes( $db, $table ); - $this->assertEquals( - array_keys( $currentIndexes ), - array_keys( $indexes ), - "mismatching indexes for table \"$table\" $versions" - ); - } - $db->close(); - } - } - - /** - * @covers DatabaseSqlite::insertId - */ - public function testInsertIdType() { - $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); - - $databaseCreation = $db->query( 'CREATE TABLE a ( a_1 )', __METHOD__ ); - $this->assertInstanceOf( ResultWrapper::class, $databaseCreation, "Database creation" ); - - $insertion = $db->insert( 'a', [ 'a_1' => 10 ], __METHOD__ ); - $this->assertTrue( $insertion, "Insertion worked" ); - - $this->assertInternalType( 'integer', $db->insertId(), "Actual typecheck" ); - $this->assertTrue( $db->close(), "closing database" ); - } - - /** - * @covers DatabaseSqlite::insert - */ - public function testInsertAffectedRows() { - $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); - $db->query( 'CREATE TABLE testInsertAffectedRows ( foo )', __METHOD__ ); - - $insertion = $db->insert( - 'testInsertAffectedRows', - [ - [ 'foo' => 10 ], - [ 'foo' => 12 ], - [ 'foo' => 1555 ], - ], - __METHOD__ - ); - $this->assertTrue( $insertion, "Insertion worked" ); - - $this->assertSame( 3, $db->affectedRows() ); - $this->assertTrue( $db->close(), "closing database" ); - } - - private function prepareTestDB( $version ) { - static $maint = null; - if ( $maint === null ) { - $maint = new FakeMaintenance(); - $maint->loadParamsAndArgs( null, [ 'quiet' => 1 ] ); - } - - global $IP; - $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); - $db->sourceFile( "$IP/tests/phpunit/data/db/sqlite/tables-$version.sql" ); - $updater = DatabaseUpdater::newForDB( $db, false, $maint ); - $updater->doUpdates( [ 'core' ] ); - - return $db; - } - - private function getTables( $db ) { - $list = array_flip( $db->listTables() ); - $excluded = [ - 'external_user', // removed from core in 1.22 - 'math', // moved out of core in 1.18 - 'trackbacks', // removed from core in 1.19 - 'searchindex', - 'searchindex_content', - 'searchindex_segments', - 'searchindex_segdir', - // FTS4 ready!!1 - 'searchindex_docsize', - 'searchindex_stat', - ]; - foreach ( $excluded as $t ) { - unset( $list[$t] ); - } - $list = array_flip( $list ); - sort( $list ); - - return $list; - } - - private function getColumns( $db, $table ) { - $cols = []; - $res = $db->query( "PRAGMA table_info($table)" ); - $this->assertNotNull( $res ); - foreach ( $res as $col ) { - $cols[$col->name] = $col; - } - ksort( $cols ); - - return $cols; - } - - private function getIndexes( $db, $table ) { - $indexes = []; - $res = $db->query( "PRAGMA index_list($table)" ); - $this->assertNotNull( $res ); - foreach ( $res as $index ) { - $res2 = $db->query( "PRAGMA index_info({$index->name})" ); - $this->assertNotNull( $res2 ); - $index->columns = []; - foreach ( $res2 as $col ) { - $index->columns[] = $col; - } - $indexes[$index->name] = $index; - } - ksort( $indexes ); - - return $indexes; - } - - /** - * @coversNothing - */ - public function testCaseInsensitiveLike() { - // TODO: Test this for all databases - $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); - $res = $db->query( 'SELECT "a" LIKE "A" AS a' ); - $row = $res->fetchRow(); - $this->assertFalse( (bool)$row['a'] ); - } - - /** - * @covers DatabaseSqlite::numFields - */ - public function testNumFields() { - $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); - - $databaseCreation = $db->query( 'CREATE TABLE a ( a_1 )', __METHOD__ ); - $this->assertInstanceOf( ResultWrapper::class, $databaseCreation, "Failed to create table a" ); - $res = $db->select( 'a', '*' ); - $this->assertEquals( 0, $db->numFields( $res ), "expects to get 0 fields for an empty table" ); - $insertion = $db->insert( 'a', [ 'a_1' => 10 ], __METHOD__ ); - $this->assertTrue( $insertion, "Insertion failed" ); - $res = $db->select( 'a', '*' ); - $this->assertEquals( 1, $db->numFields( $res ), "wrong number of fields" ); - - $this->assertTrue( $db->close(), "closing database" ); - } - - /** - * @covers \Wikimedia\Rdbms\DatabaseSqlite::__toString - */ - public function testToString() { - $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); - - $toString = (string)$db; - - $this->assertContains( 'SQLite ', $toString ); - } - - /** - * @covers \Wikimedia\Rdbms\DatabaseSqlite::getAttributes() - */ - public function testsAttributes() { - $attributes = Database::attributesFromType( 'sqlite' ); - $this->assertTrue( $attributes[Database::ATTR_DB_LEVEL_LOCKING] ); - } -} diff --git a/tests/phpunit/includes/db/DatabaseTestHelper.php b/tests/phpunit/includes/db/DatabaseTestHelper.php index fb4041dd92..43e7075ee3 100644 --- a/tests/phpunit/includes/db/DatabaseTestHelper.php +++ b/tests/phpunit/includes/db/DatabaseTestHelper.php @@ -229,10 +229,6 @@ class DatabaseTestHelper extends Database { return 'test'; } - function isOpen() { - return $this->conn ? true : false; - } - function ping( &$rtt = null ) { $rtt = 0.0; return true; diff --git a/tests/phpunit/includes/db/LBFactoryTest.php b/tests/phpunit/includes/db/LBFactoryTest.php index f7007e72e3..424c64b094 100644 --- a/tests/phpunit/includes/db/LBFactoryTest.php +++ b/tests/phpunit/includes/db/LBFactoryTest.php @@ -302,6 +302,8 @@ class LBFactoryTest extends MediaWikiTestCase { ->getMock(); $lb1->method( 'getConnection' )->willReturn( $mockDB1 ); $lb1->method( 'getServerCount' )->willReturn( 2 ); + $lb1->method( 'hasReplicaServers' )->willReturn( true ); + $lb1->method( 'hasStreamingReplicaServers' )->willReturn( true ); $lb1->method( 'getAnyOpenConnection' )->willReturn( $mockDB1 ); $lb1->method( 'hasOrMadeRecentMasterChanges' )->will( $this->returnCallback( function () use ( $mockDB1 ) { @@ -313,6 +315,7 @@ class LBFactoryTest extends MediaWikiTestCase { } ) ); $lb1->method( 'getMasterPos' )->willReturn( $m1Pos ); + $lb1->method( 'getReplicaResumePos' )->willReturn( $m1Pos ); $lb1->method( 'getServerName' )->with( 0 )->willReturn( 'master1' ); // Master DB 2 $mockDB2 = $this->getMockBuilder( IDatabase::class ) @@ -327,6 +330,8 @@ class LBFactoryTest extends MediaWikiTestCase { ->getMock(); $lb2->method( 'getConnection' )->willReturn( $mockDB2 ); $lb2->method( 'getServerCount' )->willReturn( 2 ); + $lb2->method( 'hasReplicaServers' )->willReturn( true ); + $lb2->method( 'hasStreamingReplicaServers' )->willReturn( true ); $lb2->method( 'getAnyOpenConnection' )->willReturn( $mockDB2 ); $lb2->method( 'hasOrMadeRecentMasterChanges' )->will( $this->returnCallback( function () use ( $mockDB2 ) { @@ -338,6 +343,7 @@ class LBFactoryTest extends MediaWikiTestCase { } ) ); $lb2->method( 'getMasterPos' )->willReturn( $m2Pos ); + $lb2->method( 'getReplicaResumePos' )->willReturn( $m2Pos ); $lb2->method( 'getServerName' )->with( 0 )->willReturn( 'master2' ); $bag = new HashBagOStuff(); @@ -373,6 +379,8 @@ class LBFactoryTest extends MediaWikiTestCase { ->disableOriginalConstructor() ->getMock(); $lb1->method( 'getServerCount' )->willReturn( 2 ); + $lb1->method( 'hasReplicaServers' )->willReturn( true ); + $lb1->method( 'hasStreamingReplicaServers' )->willReturn( true ); $lb1->method( 'getServerName' )->with( 0 )->willReturn( 'master1' ); $lb1->expects( $this->once() ) ->method( 'waitFor' )->with( $this->equalTo( $m1Pos ) ); @@ -381,6 +389,8 @@ class LBFactoryTest extends MediaWikiTestCase { ->disableOriginalConstructor() ->getMock(); $lb2->method( 'getServerCount' )->willReturn( 2 ); + $lb2->method( 'hasReplicaServers' )->willReturn( true ); + $lb2->method( 'hasStreamingReplicaServers' )->willReturn( true ); $lb2->method( 'getServerName' )->with( 0 )->willReturn( 'master2' ); $lb2->expects( $this->once() ) ->method( 'waitFor' )->with( $this->equalTo( $m2Pos ) ); diff --git a/tests/phpunit/includes/db/LoadBalancerTest.php b/tests/phpunit/includes/db/LoadBalancerTest.php index 7fc070c08a..bf5326a440 100644 --- a/tests/phpunit/includes/db/LoadBalancerTest.php +++ b/tests/phpunit/includes/db/LoadBalancerTest.php @@ -26,9 +26,11 @@ use Wikimedia\Rdbms\DatabaseDomain; use Wikimedia\Rdbms\Database; use Wikimedia\Rdbms\LoadBalancer; use Wikimedia\Rdbms\LoadMonitorNull; +use Wikimedia\TestingAccessWrapper; /** * @group Database + * @group medium * @covers \Wikimedia\Rdbms\LoadBalancer */ class LoadBalancerTest extends MediaWikiTestCase { @@ -49,8 +51,11 @@ class LoadBalancerTest extends MediaWikiTestCase { } /** - * @covers LoadBalancer::getLocalDomainID() - * @covers LoadBalancer::resolveDomainID() + * @covers \Wikimedia\Rdbms\LoadBalancer::getConnection() + * @covers \Wikimedia\Rdbms\LoadBalancer::getLocalDomainID() + * @covers \Wikimedia\Rdbms\LoadBalancer::resolveDomainID() + * @covers \Wikimedia\Rdbms\LoadBalancer::haveIndex() + * @covers \Wikimedia\Rdbms\LoadBalancer::isNonZeroLoad() */ public function testWithoutReplica() { global $wgDBname; @@ -66,6 +71,15 @@ class LoadBalancerTest extends MediaWikiTestCase { } ] ); + $this->assertEquals( 1, $lb->getServerCount() ); + $this->assertFalse( $lb->hasReplicaServers() ); + $this->assertFalse( $lb->hasStreamingReplicaServers() ); + + $this->assertTrue( $lb->haveIndex( 0 ) ); + $this->assertFalse( $lb->haveIndex( 1 ) ); + $this->assertFalse( $lb->isNonZeroLoad( 0 ) ); + $this->assertFalse( $lb->isNonZeroLoad( 1 ) ); + $ld = DatabaseDomain::newFromId( $lb->getLocalDomainID() ); $this->assertEquals( $wgDBname, $ld->getDatabase(), 'local domain DB set' ); $this->assertEquals( $this->dbPrefix(), $ld->getTablePrefix(), 'local domain prefix set' ); @@ -106,11 +120,38 @@ class LoadBalancerTest extends MediaWikiTestCase { $lb->closeAll(); } + /** + * @covers \Wikimedia\Rdbms\LoadBalancer::getConnection() + * @covers \Wikimedia\Rdbms\LoadBalancer::getReaderIndex() + * @covers \Wikimedia\Rdbms\LoadBalancer::getWriterIndex() + * @covers \Wikimedia\Rdbms\LoadBalancer::haveIndex() + * @covers \Wikimedia\Rdbms\LoadBalancer::isNonZeroLoad() + * @covers \Wikimedia\Rdbms\LoadBalancer::getServerName() + * @covers \Wikimedia\Rdbms\LoadBalancer::getServerInfo() + * @covers \Wikimedia\Rdbms\LoadBalancer::getServerType() + * @covers \Wikimedia\Rdbms\LoadBalancer::getServerAttributes() + */ public function testWithReplica() { global $wgDBserver; // Simulate web request with DBO_TRX - $lb = $this->newMultiServerLocalLoadBalancer( DBO_TRX ); + $lb = $this->newMultiServerLocalLoadBalancer( [], [ 'flags' => DBO_TRX ] ); + + $this->assertEquals( 8, $lb->getServerCount() ); + $this->assertTrue( $lb->hasReplicaServers() ); + $this->assertTrue( $lb->hasStreamingReplicaServers() ); + + $this->assertTrue( $lb->haveIndex( 0 ) ); + $this->assertTrue( $lb->haveIndex( 1 ) ); + $this->assertFalse( $lb->isNonZeroLoad( 0 ) ); + $this->assertTrue( $lb->isNonZeroLoad( 1 ) ); + + for ( $i = 0; $i < $lb->getServerCount(); ++$i ) { + $this->assertType( 'string', $lb->getServerName( $i ) ); + $this->assertType( 'array', $lb->getServerInfo( $i ) ); + $this->assertType( 'string', $lb->getServerType( $i ) ); + $this->assertType( 'array', $lb->getServerAttributes( $i ) ); + } $dbw = $lb->getConnection( DB_MASTER ); $this->assertTrue( $dbw->getLBInfo( 'master' ), 'master shows as master' ); @@ -123,12 +164,14 @@ class LoadBalancerTest extends MediaWikiTestCase { $dbr = $lb->getConnection( DB_REPLICA ); $this->assertTrue( $dbr->getLBInfo( 'replica' ), 'replica shows as replica' ); + $this->assertTrue( $dbr->isReadOnly(), 'replica shows as replica' ); $this->assertEquals( ( $wgDBserver != '' ) ? $wgDBserver : 'localhost', $dbr->getLBInfo( 'clusterMasterHost' ), 'cluster master set' ); $this->assertTrue( $dbr->getFlag( $dbw::DBO_TRX ), "DBO_TRX set on replica" ); $this->assertWriteForbidden( $dbr ); + $this->assertEquals( $dbr->getLBInfo( 'serverIndex' ), $lb->getReaderIndex() ); if ( !$lb->getServerAttributes( $lb->getWriterIndex() )[$dbw::ATTR_DB_LEVEL_LOCKING] ) { $dbwAuto = $lb->getConnection( DB_MASTER, [], false, $lb::CONN_TRX_AUTOCOMMIT ); @@ -161,11 +204,46 @@ class LoadBalancerTest extends MediaWikiTestCase { ] ); } - private function newMultiServerLocalLoadBalancer( $flags = DBO_DEFAULT ) { + private function newMultiServerLocalLoadBalancer( + $lbExtra = [], $srvExtra = [], $masterOnly = false + ) { global $wgDBserver, $wgDBname, $wgDBuser, $wgDBpassword, $wgDBtype, $wgSQLiteDataDir; $servers = [ - [ // master + // Master DB + 0 => $srvExtra + [ + 'host' => $wgDBserver, + 'dbname' => $wgDBname, + 'tablePrefix' => $this->dbPrefix(), + 'user' => $wgDBuser, + 'password' => $wgDBpassword, + 'type' => $wgDBtype, + 'dbDirectory' => $wgSQLiteDataDir, + 'load' => $masterOnly ? 100 : 0, + ], + // Main replica DBs + 1 => $srvExtra + [ + 'host' => $wgDBserver, + 'dbname' => $wgDBname, + 'tablePrefix' => $this->dbPrefix(), + 'user' => $wgDBuser, + 'password' => $wgDBpassword, + 'type' => $wgDBtype, + 'dbDirectory' => $wgSQLiteDataDir, + 'load' => $masterOnly ? 0 : 100, + ], + 2 => $srvExtra + [ + 'host' => $wgDBserver, + 'dbname' => $wgDBname, + 'tablePrefix' => $this->dbPrefix(), + 'user' => $wgDBuser, + 'password' => $wgDBpassword, + 'type' => $wgDBtype, + 'dbDirectory' => $wgSQLiteDataDir, + 'load' => $masterOnly ? 0 : 100, + ], + // RC replica DBs + 3 => $srvExtra + [ 'host' => $wgDBserver, 'dbname' => $wgDBname, 'tablePrefix' => $this->dbPrefix(), @@ -174,9 +252,54 @@ class LoadBalancerTest extends MediaWikiTestCase { 'type' => $wgDBtype, 'dbDirectory' => $wgSQLiteDataDir, 'load' => 0, - 'flags' => $flags + 'groupLoads' => [ + 'recentchanges' => 100, + 'watchlist' => 100 + ], ], - [ // emulated replica + // Logging replica DBs + 4 => $srvExtra + [ + 'host' => $wgDBserver, + 'dbname' => $wgDBname, + 'tablePrefix' => $this->dbPrefix(), + 'user' => $wgDBuser, + 'password' => $wgDBpassword, + 'type' => $wgDBtype, + 'dbDirectory' => $wgSQLiteDataDir, + 'load' => 0, + 'groupLoads' => [ + 'logging' => 100 + ], + ], + 5 => $srvExtra + [ + 'host' => $wgDBserver, + 'dbname' => $wgDBname, + 'tablePrefix' => $this->dbPrefix(), + 'user' => $wgDBuser, + 'password' => $wgDBpassword, + 'type' => $wgDBtype, + 'dbDirectory' => $wgSQLiteDataDir, + 'load' => 0, + 'groupLoads' => [ + 'logging' => 100 + ], + ], + // Maintenance query replica DBs + 6 => $srvExtra + [ + 'host' => $wgDBserver, + 'dbname' => $wgDBname, + 'tablePrefix' => $this->dbPrefix(), + 'user' => $wgDBuser, + 'password' => $wgDBpassword, + 'type' => $wgDBtype, + 'dbDirectory' => $wgSQLiteDataDir, + 'load' => 0, + 'groupLoads' => [ + 'vslow' => 100 + ], + ], + // Replica DB that only has a copy of some static tables + 7 => $srvExtra + [ 'host' => $wgDBserver, 'dbname' => $wgDBname, 'tablePrefix' => $this->dbPrefix(), @@ -184,12 +307,15 @@ class LoadBalancerTest extends MediaWikiTestCase { 'password' => $wgDBpassword, 'type' => $wgDBtype, 'dbDirectory' => $wgSQLiteDataDir, - 'load' => 100, - 'flags' => $flags + 'load' => 0, + 'groupLoads' => [ + 'archive' => 100 + ], + 'is static' => true ] ]; - return new LoadBalancer( [ + return new LoadBalancer( $lbExtra + [ 'servers' => $servers, 'localDomain' => new DatabaseDomain( $wgDBname, null, $this->dbPrefix() ), 'queryLogger' => MediaWiki\Logger\LoggerFactory::getInstance( 'DBQuery' ), @@ -299,8 +425,10 @@ class LoadBalancerTest extends MediaWikiTestCase { } /** - * @covers LoadBalancer::openConnection() - * @covers LoadBalancer::getAnyOpenConnection() + * @covers \Wikimedia\Rdbms\LoadBalancer::getConnection() + * @covers \Wikimedia\Rdbms\LoadBalancer::openConnection() + * @covers \Wikimedia\Rdbms\LoadBalancer::getAnyOpenConnection() + * @covers \Wikimedia\Rdbms\LoadBalancer::getWriterIndex() */ function testOpenConnection() { $lb = $this->newSingleServerLocalLoadBalancer(); @@ -346,6 +474,18 @@ class LoadBalancerTest extends MediaWikiTestCase { $lb->closeAll(); } + /** + * @covers \Wikimedia\Rdbms\LoadBalancer::openConnection() + * @covers \Wikimedia\Rdbms\LoadBalancer::getWriterIndex() + * @covers \Wikimedia\Rdbms\LoadBalancer::forEachOpenMasterConnection() + * @covers \Wikimedia\Rdbms\LoadBalancer::setTransactionListener() + * @covers \Wikimedia\Rdbms\LoadBalancer::beginMasterChanges() + * @covers \Wikimedia\Rdbms\LoadBalancer::finalizeMasterChanges() + * @covers \Wikimedia\Rdbms\LoadBalancer::approveMasterChanges() + * @covers \Wikimedia\Rdbms\LoadBalancer::commitMasterChanges() + * @covers \Wikimedia\Rdbms\LoadBalancer::runMasterTransactionIdleCallbacks() + * @covers \Wikimedia\Rdbms\LoadBalancer::runMasterTransactionListenerCallbacks() + */ public function testTransactionCallbackChains() { global $wgDBserver, $wgDBname, $wgDBuser, $wgDBpassword, $wgDBtype, $wgSQLiteDataDir; @@ -433,6 +573,10 @@ class LoadBalancerTest extends MediaWikiTestCase { $conn2->close(); } + /** + * @covers \Wikimedia\Rdbms\LoadBalancer::getConnectionRef + * @covers \Wikimedia\Rdbms\LoadBalancer::getConnection() + */ public function testDBConnRefReadsMasterAndReplicaRoles() { $lb = $this->newSingleServerLocalLoadBalancer(); @@ -457,6 +601,7 @@ class LoadBalancerTest extends MediaWikiTestCase { } /** + * @covers \Wikimedia\Rdbms\LoadBalancer::getConnectionRef * @expectedException \Wikimedia\Rdbms\DBReadOnlyRoleError */ public function testDBConnRefWritesReplicaRole() { @@ -468,6 +613,7 @@ class LoadBalancerTest extends MediaWikiTestCase { } /** + * @covers \Wikimedia\Rdbms\LoadBalancer::getConnectionRef * @expectedException \Wikimedia\Rdbms\DBReadOnlyRoleError */ public function testDBConnRefWritesReplicaRoleIndex() { @@ -479,6 +625,7 @@ class LoadBalancerTest extends MediaWikiTestCase { } /** + * @covers \Wikimedia\Rdbms\LoadBalancer::getConnectionRef * @expectedException \Wikimedia\Rdbms\DBReadOnlyRoleError */ public function testDBConnRefWritesReplicaRoleInsert() { @@ -488,4 +635,116 @@ class LoadBalancerTest extends MediaWikiTestCase { $rConn->insert( 'test', [ 't' => 1 ], __METHOD__ ); } + + /** + * @covers \Wikimedia\Rdbms\LoadBalancer::getConnection() + */ + public function testGetConnectionRefDefaultGroup() { + $lb = $this->newMultiServerLocalLoadBalancer( [ 'defaultGroup' => 'vslow' ] ); + $lbWrapper = TestingAccessWrapper::newFromObject( $lb ); + + $rVslow = $lb->getConnectionRef( DB_REPLICA ); + $vslowIndexPicked = $rVslow->getLBInfo( 'serverIndex' ); + + $this->assertSame( $vslowIndexPicked, $lbWrapper->getExistingReaderIndex( 'vslow' ) ); + } + + /** + * @covers \Wikimedia\Rdbms\LoadBalancer::getConnection() + */ + public function testGetConnectionRefUnknownDefaultGroup() { + $lb = $this->newMultiServerLocalLoadBalancer( [ 'defaultGroup' => 'invalid' ] ); + + $this->assertInstanceOf( + IDatabase::class, + $lb->getConnectionRef( DB_REPLICA ) + ); + } + + /** + * @covers \Wikimedia\Rdbms\LoadBalancer::getConnection() + * @covers \Wikimedia\Rdbms\LoadBalancer::getMaintenanceConnectionRef() + */ + public function testQueryGroupIndex() { + $lb = $this->newMultiServerLocalLoadBalancer( [ 'defaultGroup' => false ] ); + /** @var LoadBalancer $lbWrapper */ + $lbWrapper = TestingAccessWrapper::newFromObject( $lb ); + + $rGeneric = $lb->getConnectionRef( DB_REPLICA ); + $mainIndexPicked = $rGeneric->getLBInfo( 'serverIndex' ); + + $this->assertEquals( + $mainIndexPicked, + $lbWrapper->getExistingReaderIndex( $lb::GROUP_GENERIC ) + ); + $this->assertTrue( in_array( $mainIndexPicked, [ 1, 2 ] ) ); + for ( $i = 0; $i < 300; ++$i ) { + $rLog = $lb->getConnectionRef( DB_REPLICA, [] ); + $this->assertEquals( + $mainIndexPicked, + $rLog->getLBInfo( 'serverIndex' ), + "Main index unchanged" ); + } + + $rRC = $lb->getConnectionRef( DB_REPLICA, [ 'recentchanges' ] ); + $rWL = $lb->getConnectionRef( DB_REPLICA, [ 'watchlist' ] ); + $rRCMaint = $lb->getMaintenanceConnectionRef( DB_REPLICA, [ 'recentchanges' ] ); + $rWLMaint = $lb->getMaintenanceConnectionRef( DB_REPLICA, [ 'watchlist' ] ); + + $this->assertEquals( 3, $rRC->getLBInfo( 'serverIndex' ) ); + $this->assertEquals( 3, $rWL->getLBInfo( 'serverIndex' ) ); + $this->assertEquals( 3, $rRCMaint->getLBInfo( 'serverIndex' ) ); + $this->assertEquals( 3, $rWLMaint->getLBInfo( 'serverIndex' ) ); + + $rLog = $lb->getConnectionRef( DB_REPLICA, [ 'logging', 'watchlist' ] ); + $logIndexPicked = $rLog->getLBInfo( 'serverIndex' ); + + $this->assertEquals( $logIndexPicked, $lbWrapper->getExistingReaderIndex( 'logging' ) ); + $this->assertTrue( in_array( $logIndexPicked, [ 4, 5 ] ) ); + + for ( $i = 0; $i < 300; ++$i ) { + $rLog = $lb->getConnectionRef( DB_REPLICA, [ 'logging', 'watchlist' ] ); + $this->assertEquals( + $logIndexPicked, $rLog->getLBInfo( 'serverIndex' ), "Index unchanged" ); + } + + $rVslow = $lb->getConnectionRef( DB_REPLICA, [ 'vslow', 'logging' ] ); + $vslowIndexPicked = $rVslow->getLBInfo( 'serverIndex' ); + + $this->assertEquals( $vslowIndexPicked, $lbWrapper->getExistingReaderIndex( 'vslow' ) ); + $this->assertEquals( 6, $vslowIndexPicked ); + } + + public function testNonZeroMasterLoad() { + $lb = $this->newMultiServerLocalLoadBalancer( [], [ 'flags' => DBO_DEFAULT ], true ); + // Make sure that no infinite loop occurs (T226678) + $rGeneric = $lb->getConnectionRef( DB_REPLICA ); + $this->assertEquals( $lb->getWriterIndex(), $rGeneric->getLBInfo( 'serverIndex' ) ); + } + + /** + * @covers \Wikimedia\Rdbms\LoadBalancer::getLazyConnectionRef + */ + public function testGetLazyConnectionRef() { + $lb = $this->newMultiServerLocalLoadBalancer(); + + $rMaster = $lb->getLazyConnectionRef( DB_MASTER ); + $rReplica = $lb->getLazyConnectionRef( 1 ); + $this->assertFalse( $lb->getAnyOpenConnection( 0 ) ); + $this->assertFalse( $lb->getAnyOpenConnection( 1 ) ); + + $rMaster->getType(); + $rReplica->getType(); + $rMaster->getDomainID(); + $rReplica->getDomainID(); + $this->assertFalse( $lb->getAnyOpenConnection( 0 ) ); + $this->assertFalse( $lb->getAnyOpenConnection( 1 ) ); + + $rMaster->query( "SELECT 1", __METHOD__ ); + $this->assertNotFalse( $lb->getAnyOpenConnection( 0 ) ); + + $rReplica->query( "SELECT 1", __METHOD__ ); + $this->assertNotFalse( $lb->getAnyOpenConnection( 0 ) ); + $this->assertNotFalse( $lb->getAnyOpenConnection( 1 ) ); + } } diff --git a/tests/phpunit/includes/debug/DeprecationHelperTest.php b/tests/phpunit/includes/debug/DeprecationHelperTest.php index b14d89c3f4..25dedbc2b0 100644 --- a/tests/phpunit/includes/debug/DeprecationHelperTest.php +++ b/tests/phpunit/includes/debug/DeprecationHelperTest.php @@ -37,10 +37,8 @@ class DeprecationHelperTest extends MediaWikiTestCase { public function provideGet() { return [ - [ 'protectedDeprecated', null, null ], [ 'protectedNonDeprecated', E_USER_ERROR, 'Cannot access non-public property TestDeprecatedClass::$protectedNonDeprecated' ], - [ 'privateDeprecated', null, null ], [ 'privateNonDeprecated', E_USER_ERROR, 'Cannot access non-public property TestDeprecatedClass::$privateNonDeprecated' ], [ 'nonExistent', E_USER_NOTICE, 'Undefined property: TestDeprecatedClass::$nonExistent' ], @@ -71,10 +69,8 @@ class DeprecationHelperTest extends MediaWikiTestCase { public function provideSet() { return [ - [ 'protectedDeprecated', null, null ], [ 'protectedNonDeprecated', E_USER_ERROR, 'Cannot access non-public property TestDeprecatedClass::$protectedNonDeprecated' ], - [ 'privateDeprecated', null, null ], [ 'privateNonDeprecated', E_USER_ERROR, 'Cannot access non-public property TestDeprecatedClass::$privateNonDeprecated' ], [ 'nonExistent', null, null ], @@ -100,15 +96,6 @@ class DeprecationHelperTest extends MediaWikiTestCase { } public function testSubclassGetSet() { - $this->assertDeprecationWarningIssued( function () { - $this->assertSame( 1, $this->testSubclass->getDeprecatedPrivateParentProperty() ); - } ); - $this->assertDeprecationWarningIssued( function () { - $this->testSubclass->setDeprecatedPrivateParentProperty( 0 ); - } ); - $wrapper = TestingAccessWrapper::newFromObject( $this->testSubclass ); - $this->assertSame( 0, $wrapper->privateDeprecated ); - $fullName = 'TestDeprecatedClass::$privateNonDeprecated'; $this->assertErrorTriggered( function () { $this->assertSame( null, $this->testSubclass->getNonDeprecatedPrivateParentProperty() ); @@ -165,4 +152,22 @@ class DeprecationHelperTest extends MediaWikiTestCase { $this->assertNotEmpty( $wrapper->deprecationWarnings ); } + /** + * Test bad MW version values to throw exceptions as expected + * + * @dataProvider provideBadMWVersion + */ + public function testBadMWVersion( $version, $expected ) { + $this->setExpectedException( $expected ); + + wfDeprecated( __METHOD__, $version ); + } + + public function provideBadMWVersion() { + return [ + [ 1, Exception::class ], + [ 1.33, Exception::class ], + [ null, Exception::class ] + ]; + } } diff --git a/tests/phpunit/includes/debug/logger/MonologSpiTest.php b/tests/phpunit/includes/debug/logger/MonologSpiTest.php deleted file mode 100644 index fda3ac614a..0000000000 --- a/tests/phpunit/includes/debug/logger/MonologSpiTest.php +++ /dev/null @@ -1,136 +0,0 @@ - [ - '@default' => [ - 'processors' => [ 'constructor' ], - 'handlers' => [ 'constructor' ], - ], - ], - 'processors' => [ - 'constructor' => [ - 'class' => 'constructor', - ], - ], - 'handlers' => [ - 'constructor' => [ - 'class' => 'constructor', - 'formatter' => 'constructor', - ], - ], - 'formatters' => [ - 'constructor' => [ - 'class' => 'constructor', - ], - ], - ]; - - $fixture = new MonologSpi( $base ); - $this->assertSame( - $base, - TestingAccessWrapper::newFromObject( $fixture )->config - ); - - $fixture->mergeConfig( [ - 'loggers' => [ - 'merged' => [ - 'processors' => [ 'merged' ], - 'handlers' => [ 'merged' ], - ], - ], - 'processors' => [ - 'merged' => [ - 'class' => 'merged', - ], - ], - 'magic' => [ - 'idkfa' => [ 'xyzzy' ], - ], - 'handlers' => [ - 'merged' => [ - 'class' => 'merged', - 'formatter' => 'merged', - ], - ], - 'formatters' => [ - 'merged' => [ - 'class' => 'merged', - ], - ], - ] ); - $this->assertSame( - [ - 'loggers' => [ - '@default' => [ - 'processors' => [ 'constructor' ], - 'handlers' => [ 'constructor' ], - ], - 'merged' => [ - 'processors' => [ 'merged' ], - 'handlers' => [ 'merged' ], - ], - ], - 'processors' => [ - 'constructor' => [ - 'class' => 'constructor', - ], - 'merged' => [ - 'class' => 'merged', - ], - ], - 'handlers' => [ - 'constructor' => [ - 'class' => 'constructor', - 'formatter' => 'constructor', - ], - 'merged' => [ - 'class' => 'merged', - 'formatter' => 'merged', - ], - ], - 'formatters' => [ - 'constructor' => [ - 'class' => 'constructor', - ], - 'merged' => [ - 'class' => 'merged', - ], - ], - 'magic' => [ - 'idkfa' => [ 'xyzzy' ], - ], - ], - TestingAccessWrapper::newFromObject( $fixture )->config - ); - } - -} diff --git a/tests/phpunit/includes/debug/logger/monolog/AvroFormatterTest.php b/tests/phpunit/includes/debug/logger/monolog/AvroFormatterTest.php deleted file mode 100644 index baa4df7390..0000000000 --- a/tests/phpunit/includes/debug/logger/monolog/AvroFormatterTest.php +++ /dev/null @@ -1,76 +0,0 @@ -markTestSkipped( 'Avro is required for the AvroFormatterTest' ); - } - parent::setUp(); - } - - public function testSchemaNotAvailable() { - $formatter = new AvroFormatter( [] ); - $this->setExpectedException( - 'PHPUnit_Framework_Error_Notice', - "The schema for channel 'marty' is not available" - ); - $formatter->format( [ 'channel' => 'marty' ] ); - } - - public function testSchemaNotAvailableReturnValue() { - $formatter = new AvroFormatter( [] ); - $noticeEnabled = PHPUnit_Framework_Error_Notice::$enabled; - // disable conversion of notices - PHPUnit_Framework_Error_Notice::$enabled = false; - // have to keep the user notice from being output - \Wikimedia\suppressWarnings(); - $res = $formatter->format( [ 'channel' => 'marty' ] ); - \Wikimedia\restoreWarnings(); - PHPUnit_Framework_Error_Notice::$enabled = $noticeEnabled; - $this->assertNull( $res ); - } - - public function testDoesSomethingWhenSchemaAvailable() { - $formatter = new AvroFormatter( [ - 'string' => [ - 'schema' => [ 'type' => 'string' ], - 'revision' => 1010101, - ] - ] ); - $res = $formatter->format( [ - 'channel' => 'string', - 'context' => 'better to be', - ] ); - $this->assertNotNull( $res ); - // basically just tell us if avro changes its string encoding, or if - // we completely fail to generate a log message. - $this->assertEquals( 'AAAAAAAAD2m1GGJldHRlciB0byBiZQ==', base64_encode( $res ) ); - } -} diff --git a/tests/phpunit/includes/debug/logger/monolog/CeeFormatterTest.php b/tests/phpunit/includes/debug/logger/monolog/CeeFormatterTest.php deleted file mode 100644 index b30c7a4c92..0000000000 --- a/tests/phpunit/includes/debug/logger/monolog/CeeFormatterTest.php +++ /dev/null @@ -1,20 +0,0 @@ - [ 'url' => 1 ], 'context' => [ 'url' => 2 ] ]; - $this->assertSame( - $cee_formatter->format( $record ), - "@cee: " . $ls_formatter->format( $record ) ); - } -} diff --git a/tests/phpunit/includes/debug/logger/monolog/KafkaHandlerTest.php b/tests/phpunit/includes/debug/logger/monolog/KafkaHandlerTest.php deleted file mode 100644 index 4c0ca04fef..0000000000 --- a/tests/phpunit/includes/debug/logger/monolog/KafkaHandlerTest.php +++ /dev/null @@ -1,227 +0,0 @@ -markTestSkipped( 'Monolog and Kafka are required for the KafkaHandlerTest' ); - } - - parent::setUp(); - } - - public function topicNamingProvider() { - return [ - [ [], 'monolog_foo' ], - [ [ 'alias' => [ 'foo' => 'bar' ] ], 'bar' ] - ]; - } - - /** - * @dataProvider topicNamingProvider - */ - public function testTopicNaming( $options, $expect ) { - $produce = $this->getMockBuilder( 'Kafka\Produce' ) - ->disableOriginalConstructor() - ->getMock(); - $produce->expects( $this->any() ) - ->method( 'getAvailablePartitions' ) - ->will( $this->returnValue( [ 'A' ] ) ); - $produce->expects( $this->once() ) - ->method( 'setMessages' ) - ->with( $expect, $this->anything(), $this->anything() ); - $produce->expects( $this->any() ) - ->method( 'send' ) - ->will( $this->returnValue( true ) ); - - $handler = new KafkaHandler( $produce, $options ); - $handler->handle( [ - 'channel' => 'foo', - 'level' => Logger::EMERGENCY, - 'extra' => [], - 'context' => [], - ] ); - } - - public function swallowsExceptionsWhenRequested() { - return [ - // defaults to false - [ [], true ], - // also try false explicitly - [ [ 'swallowExceptions' => false ], true ], - // turn it on - [ [ 'swallowExceptions' => true ], false ], - ]; - } - - /** - * @dataProvider swallowsExceptionsWhenRequested - */ - public function testGetAvailablePartitionsException( $options, $expectException ) { - $produce = $this->getMockBuilder( 'Kafka\Produce' ) - ->disableOriginalConstructor() - ->getMock(); - $produce->expects( $this->any() ) - ->method( 'getAvailablePartitions' ) - ->will( $this->throwException( new \Kafka\Exception ) ); - $produce->expects( $this->any() ) - ->method( 'send' ) - ->will( $this->returnValue( true ) ); - - if ( $expectException ) { - $this->setExpectedException( 'Kafka\Exception' ); - } - - $handler = new KafkaHandler( $produce, $options ); - $handler->handle( [ - 'channel' => 'foo', - 'level' => Logger::EMERGENCY, - 'extra' => [], - 'context' => [], - ] ); - - if ( !$expectException ) { - $this->assertTrue( true, 'no exception was thrown' ); - } - } - - /** - * @dataProvider swallowsExceptionsWhenRequested - */ - public function testSendException( $options, $expectException ) { - $produce = $this->getMockBuilder( 'Kafka\Produce' ) - ->disableOriginalConstructor() - ->getMock(); - $produce->expects( $this->any() ) - ->method( 'getAvailablePartitions' ) - ->will( $this->returnValue( [ 'A' ] ) ); - $produce->expects( $this->any() ) - ->method( 'send' ) - ->will( $this->throwException( new \Kafka\Exception ) ); - - if ( $expectException ) { - $this->setExpectedException( 'Kafka\Exception' ); - } - - $handler = new KafkaHandler( $produce, $options ); - $handler->handle( [ - 'channel' => 'foo', - 'level' => Logger::EMERGENCY, - 'extra' => [], - 'context' => [], - ] ); - - if ( !$expectException ) { - $this->assertTrue( true, 'no exception was thrown' ); - } - } - - public function testHandlesNullFormatterResult() { - $produce = $this->getMockBuilder( 'Kafka\Produce' ) - ->disableOriginalConstructor() - ->getMock(); - $produce->expects( $this->any() ) - ->method( 'getAvailablePartitions' ) - ->will( $this->returnValue( [ 'A' ] ) ); - $mockMethod = $produce->expects( $this->exactly( 2 ) ) - ->method( 'setMessages' ); - $produce->expects( $this->any() ) - ->method( 'send' ) - ->will( $this->returnValue( true ) ); - // evil hax - $matcher = TestingAccessWrapper::newFromObject( $mockMethod )->matcher; - TestingAccessWrapper::newFromObject( $matcher )->parametersMatcher = - new \PHPUnit_Framework_MockObject_Matcher_ConsecutiveParameters( [ - [ $this->anything(), $this->anything(), [ 'words' ] ], - [ $this->anything(), $this->anything(), [ 'lines' ] ] - ] ); - - $formatter = $this->createMock( \Monolog\Formatter\FormatterInterface::class ); - $formatter->expects( $this->any() ) - ->method( 'format' ) - ->will( $this->onConsecutiveCalls( 'words', null, 'lines' ) ); - - $handler = new KafkaHandler( $produce, [] ); - $handler->setFormatter( $formatter ); - for ( $i = 0; $i < 3; ++$i ) { - $handler->handle( [ - 'channel' => 'foo', - 'level' => Logger::EMERGENCY, - 'extra' => [], - 'context' => [], - ] ); - } - } - - public function testBatchHandlesNullFormatterResult() { - $produce = $this->getMockBuilder( 'Kafka\Produce' ) - ->disableOriginalConstructor() - ->getMock(); - $produce->expects( $this->any() ) - ->method( 'getAvailablePartitions' ) - ->will( $this->returnValue( [ 'A' ] ) ); - $produce->expects( $this->once() ) - ->method( 'setMessages' ) - ->with( $this->anything(), $this->anything(), [ 'words', 'lines' ] ); - $produce->expects( $this->any() ) - ->method( 'send' ) - ->will( $this->returnValue( true ) ); - - $formatter = $this->createMock( \Monolog\Formatter\FormatterInterface::class ); - $formatter->expects( $this->any() ) - ->method( 'format' ) - ->will( $this->onConsecutiveCalls( 'words', null, 'lines' ) ); - - $handler = new KafkaHandler( $produce, [] ); - $handler->setFormatter( $formatter ); - $handler->handleBatch( [ - [ - 'channel' => 'foo', - 'level' => Logger::EMERGENCY, - 'extra' => [], - 'context' => [], - ], - [ - 'channel' => 'foo', - 'level' => Logger::EMERGENCY, - 'extra' => [], - 'context' => [], - ], - [ - 'channel' => 'foo', - 'level' => Logger::EMERGENCY, - 'extra' => [], - 'context' => [], - ], - ] ); - } -} diff --git a/tests/phpunit/includes/debug/logger/monolog/LineFormatterTest.php b/tests/phpunit/includes/debug/logger/monolog/LineFormatterTest.php deleted file mode 100644 index bdd5c81118..0000000000 --- a/tests/phpunit/includes/debug/logger/monolog/LineFormatterTest.php +++ /dev/null @@ -1,122 +0,0 @@ -markTestSkipped( 'This test requires monolog to be installed' ); - } - parent::setUp(); - } - - /** - * @covers MediaWiki\Logger\Monolog\LineFormatter::normalizeException - */ - public function testNormalizeExceptionNoTrace() { - $fixture = new LineFormatter(); - $fixture->includeStacktraces( false ); - $fixture = TestingAccessWrapper::newFromObject( $fixture ); - $boom = new InvalidArgumentException( 'boom', 0, - new LengthException( 'too long', 0, - new LogicException( 'Spock wuz here' ) - ) - ); - $out = $fixture->normalizeException( $boom ); - $this->assertContains( "\n[Exception InvalidArgumentException]", $out ); - $this->assertContains( "\nCaused by: [Exception LengthException]", $out ); - $this->assertContains( "\nCaused by: [Exception LogicException]", $out ); - $this->assertNotContains( "\n #0", $out ); - } - - /** - * @covers MediaWiki\Logger\Monolog\LineFormatter::normalizeException - */ - public function testNormalizeExceptionTrace() { - $fixture = new LineFormatter(); - $fixture->includeStacktraces( true ); - $fixture = TestingAccessWrapper::newFromObject( $fixture ); - $boom = new InvalidArgumentException( 'boom', 0, - new LengthException( 'too long', 0, - new LogicException( 'Spock wuz here' ) - ) - ); - $out = $fixture->normalizeException( $boom ); - $this->assertContains( "\n[Exception InvalidArgumentException]", $out ); - $this->assertContains( "\nCaused by: [Exception LengthException]", $out ); - $this->assertContains( "\nCaused by: [Exception LogicException]", $out ); - $this->assertContains( "\n #0", $out ); - } - - /** - * @covers MediaWiki\Logger\Monolog\LineFormatter::normalizeException - */ - public function testNormalizeExceptionErrorNoTrace() { - if ( !class_exists( AssertionError::class ) ) { - $this->markTestSkipped( 'AssertionError class does not exist' ); - } - - $fixture = new LineFormatter(); - $fixture->includeStacktraces( false ); - $fixture = TestingAccessWrapper::newFromObject( $fixture ); - $boom = new InvalidArgumentException( 'boom', 0, - new LengthException( 'too long', 0, - new AssertionError( 'Spock wuz here' ) - ) - ); - $out = $fixture->normalizeException( $boom ); - $this->assertContains( "\n[Exception InvalidArgumentException]", $out ); - $this->assertContains( "\nCaused by: [Exception LengthException]", $out ); - $this->assertContains( "\nCaused by: [Error AssertionError]", $out ); - $this->assertNotContains( "\n #0", $out ); - } - - /** - * @covers MediaWiki\Logger\Monolog\LineFormatter::normalizeException - */ - public function testNormalizeExceptionErrorTrace() { - if ( !class_exists( AssertionError::class ) ) { - $this->markTestSkipped( 'AssertionError class does not exist' ); - } - - $fixture = new LineFormatter(); - $fixture->includeStacktraces( true ); - $fixture = TestingAccessWrapper::newFromObject( $fixture ); - $boom = new InvalidArgumentException( 'boom', 0, - new LengthException( 'too long', 0, - new AssertionError( 'Spock wuz here' ) - ) - ); - $out = $fixture->normalizeException( $boom ); - $this->assertContains( "\n[Exception InvalidArgumentException]", $out ); - $this->assertContains( "\nCaused by: [Exception LengthException]", $out ); - $this->assertContains( "\nCaused by: [Error AssertionError]", $out ); - $this->assertContains( "\n #0", $out ); - } -} diff --git a/tests/phpunit/includes/debug/logger/monolog/LogstashFormatterTest.php b/tests/phpunit/includes/debug/logger/monolog/LogstashFormatterTest.php deleted file mode 100644 index a1207b26b2..0000000000 --- a/tests/phpunit/includes/debug/logger/monolog/LogstashFormatterTest.php +++ /dev/null @@ -1,59 +0,0 @@ -format( $record ), true ); - foreach ( $expected as $key => $value ) { - $this->assertArrayHasKey( $key, $formatted ); - $this->assertSame( $value, $formatted[$key] ); - } - foreach ( $notExpected as $key ) { - $this->assertArrayNotHasKey( $key, $formatted ); - } - } - - public function provideV1() { - return [ - [ - [ 'extra' => [ 'foo' => 1 ], 'context' => [ 'bar' => 2 ] ], - [ 'foo' => 1, 'bar' => 2 ], - [ 'logstash_formatter_key_conflict' ], - ], - [ - [ 'extra' => [ 'url' => 1 ], 'context' => [ 'url' => 2 ] ], - [ 'url' => 1, 'c_url' => 2, 'logstash_formatter_key_conflict' => [ 'url' ] ], - [], - ], - [ - [ 'channel' => 'x', 'context' => [ 'channel' => 'y' ] ], - [ 'channel' => 'x', 'c_channel' => 'y', - 'logstash_formatter_key_conflict' => [ 'channel' ] ], - [], - ], - ]; - } - - /** - * @covers MediaWiki\Logger\Monolog\LogstashFormatter::formatV1 - */ - public function testV1WithPrefix() { - $formatter = new LogstashFormatter( 'app', 'system', null, 'ctx_', LogstashFormatter::V1 ); - $record = [ 'extra' => [ 'url' => 1 ], 'context' => [ 'url' => 2 ] ]; - $formatted = json_decode( $formatter->format( $record ), true ); - $this->assertArrayHasKey( 'url', $formatted ); - $this->assertSame( 1, $formatted['url'] ); - $this->assertArrayHasKey( 'ctx_url', $formatted ); - $this->assertSame( 2, $formatted['ctx_url'] ); - $this->assertArrayNotHasKey( 'c_url', $formatted ); - } -} diff --git a/tests/phpunit/includes/deferred/MWCallableUpdateTest.php b/tests/phpunit/includes/deferred/MWCallableUpdateTest.php deleted file mode 100644 index 3ab9b5659e..0000000000 --- a/tests/phpunit/includes/deferred/MWCallableUpdateTest.php +++ /dev/null @@ -1,82 +0,0 @@ -assertSame( 0, $ran ); - $update->doUpdate(); - $this->assertSame( 1, $ran ); - } - - public function testCancel() { - // Prepare update and DB - $db = new DatabaseTestHelper( __METHOD__ ); - $db->begin( __METHOD__ ); - $ran = 0; - $update = new MWCallableUpdate( function () use ( &$ran ) { - $ran++; - }, __METHOD__, $db ); - - // Emulate rollback - $db->rollback( __METHOD__ ); - - $update->doUpdate(); - - // Ensure it was cancelled - $this->assertSame( 0, $ran ); - } - - public function testCancelSome() { - // Prepare update and DB - $db1 = new DatabaseTestHelper( __METHOD__ ); - $db1->begin( __METHOD__ ); - $db2 = new DatabaseTestHelper( __METHOD__ ); - $db2->begin( __METHOD__ ); - $ran = 0; - $update = new MWCallableUpdate( function () use ( &$ran ) { - $ran++; - }, __METHOD__, [ $db1, $db2 ] ); - - // Emulate rollback - $db1->rollback( __METHOD__ ); - - $update->doUpdate(); - - // Prevents: "Notice: DB transaction writes or callbacks still pending" - $db2->rollback( __METHOD__ ); - - // Ensure it was cancelled - $this->assertSame( 0, $ran ); - } - - public function testCancelAll() { - // Prepare update and DB - $db1 = new DatabaseTestHelper( __METHOD__ ); - $db1->begin( __METHOD__ ); - $db2 = new DatabaseTestHelper( __METHOD__ ); - $db2->begin( __METHOD__ ); - $ran = 0; - $update = new MWCallableUpdate( function () use ( &$ran ) { - $ran++; - }, __METHOD__, [ $db1, $db2 ] ); - - // Emulate rollbacks - $db1->rollback( __METHOD__ ); - $db2->rollback( __METHOD__ ); - - $update->doUpdate(); - - // Ensure it was cancelled - $this->assertSame( 0, $ran ); - } - -} diff --git a/tests/phpunit/includes/deferred/SearchUpdateTest.php b/tests/phpunit/includes/deferred/SearchUpdateTest.php index 74a5e3c470..8faaeda0d2 100644 --- a/tests/phpunit/includes/deferred/SearchUpdateTest.php +++ b/tests/phpunit/includes/deferred/SearchUpdateTest.php @@ -76,9 +76,6 @@ class MockSearch extends SearchEngine { public static $title; public static $text; - public function __construct( $db ) { - } - public function update( $id, $title, $text ) { self::$id = $id; self::$title = $title; diff --git a/tests/phpunit/includes/deferred/SiteStatsUpdateTest.php b/tests/phpunit/includes/deferred/SiteStatsUpdateTest.php index 83e9a47ca6..ccfcc181ee 100644 --- a/tests/phpunit/includes/deferred/SiteStatsUpdateTest.php +++ b/tests/phpunit/includes/deferred/SiteStatsUpdateTest.php @@ -42,11 +42,13 @@ class SiteStatsUpdateTest extends MediaWikiTestCase { $fi = SiteStats::images(); $ai = SiteStats::articles(); + $this->assertEquals( 0, DeferredUpdates::pendingUpdatesCount() ); + $dbw->begin( __METHOD__ ); // block opportunistic updates - $update = SiteStatsUpdate::factory( [ 'pages' => 2, 'images' => 1, 'edits' => 2 ] ); - $this->assertEquals( 0, DeferredUpdates::pendingUpdatesCount() ); - $update->doUpdate(); + DeferredUpdates::addUpdate( + SiteStatsUpdate::factory( [ 'pages' => 2, 'images' => 1, 'edits' => 2 ] ) + ); $this->assertEquals( 1, DeferredUpdates::pendingUpdatesCount() ); // Still the same diff --git a/tests/phpunit/includes/deferred/TransactionRoundDefiningUpdateTest.php b/tests/phpunit/includes/deferred/TransactionRoundDefiningUpdateTest.php deleted file mode 100644 index 693897e676..0000000000 --- a/tests/phpunit/includes/deferred/TransactionRoundDefiningUpdateTest.php +++ /dev/null @@ -1,19 +0,0 @@ -assertSame( 0, $ran ); - $update->doUpdate(); - $this->assertSame( 1, $ran ); - } -} diff --git a/tests/phpunit/includes/diff/ArrayDiffFormatterTest.php b/tests/phpunit/includes/diff/ArrayDiffFormatterTest.php deleted file mode 100644 index 8d94404cd4..0000000000 --- a/tests/phpunit/includes/diff/ArrayDiffFormatterTest.php +++ /dev/null @@ -1,134 +0,0 @@ -format( $input ); - $this->assertEquals( $expectedOutput, $output ); - } - - private function getMockDiff( $edits ) { - $diff = $this->getMockBuilder( Diff::class ) - ->disableOriginalConstructor() - ->getMock(); - $diff->expects( $this->any() ) - ->method( 'getEdits' ) - ->will( $this->returnValue( $edits ) ); - return $diff; - } - - private function getMockDiffOp( $type = null, $orig = [], $closing = [] ) { - $diffOp = $this->getMockBuilder( DiffOp::class ) - ->disableOriginalConstructor() - ->getMock(); - $diffOp->expects( $this->any() ) - ->method( 'getType' ) - ->will( $this->returnValue( $type ) ); - $diffOp->expects( $this->any() ) - ->method( 'getOrig' ) - ->will( $this->returnValue( $orig ) ); - if ( $type === 'change' ) { - $diffOp->expects( $this->any() ) - ->method( 'getClosing' ) - ->with( $this->isType( 'integer' ) ) - ->will( $this->returnCallback( function () { - return 'mockLine'; - } ) ); - } else { - $diffOp->expects( $this->any() ) - ->method( 'getClosing' ) - ->will( $this->returnValue( $closing ) ); - } - return $diffOp; - } - - public function provideTestFormat() { - $emptyArrayTestCases = [ - $this->getMockDiff( [] ), - $this->getMockDiff( [ $this->getMockDiffOp( 'add' ) ] ), - $this->getMockDiff( [ $this->getMockDiffOp( 'delete' ) ] ), - $this->getMockDiff( [ $this->getMockDiffOp( 'change' ) ] ), - $this->getMockDiff( [ $this->getMockDiffOp( 'copy' ) ] ), - $this->getMockDiff( [ $this->getMockDiffOp( 'FOOBARBAZ' ) ] ), - $this->getMockDiff( [ $this->getMockDiffOp( 'add', 'line' ) ] ), - $this->getMockDiff( [ $this->getMockDiffOp( 'delete', [], [ 'line' ] ) ] ), - $this->getMockDiff( [ $this->getMockDiffOp( 'copy', [], [ 'line' ] ) ] ), - ]; - - $otherTestCases = []; - $otherTestCases[] = [ - $this->getMockDiff( [ $this->getMockDiffOp( 'add', [], [ 'a1' ] ) ] ), - [ [ 'action' => 'add', 'new' => 'a1', 'newline' => 1 ] ], - ]; - $otherTestCases[] = [ - $this->getMockDiff( [ $this->getMockDiffOp( 'add', [], [ 'a1', 'a2' ] ) ] ), - [ - [ 'action' => 'add', 'new' => 'a1', 'newline' => 1 ], - [ 'action' => 'add', 'new' => 'a2', 'newline' => 2 ], - ], - ]; - $otherTestCases[] = [ - $this->getMockDiff( [ $this->getMockDiffOp( 'delete', [ 'd1' ] ) ] ), - [ [ 'action' => 'delete', 'old' => 'd1', 'oldline' => 1 ] ], - ]; - $otherTestCases[] = [ - $this->getMockDiff( [ $this->getMockDiffOp( 'delete', [ 'd1', 'd2' ] ) ] ), - [ - [ 'action' => 'delete', 'old' => 'd1', 'oldline' => 1 ], - [ 'action' => 'delete', 'old' => 'd2', 'oldline' => 2 ], - ], - ]; - $otherTestCases[] = [ - $this->getMockDiff( [ $this->getMockDiffOp( 'change', [ 'd1' ], [ 'a1' ] ) ] ), - [ [ - 'action' => 'change', - 'old' => 'd1', - 'new' => 'mockLine', - 'newline' => 1, 'oldline' => 1 - ] ], - ]; - $otherTestCases[] = [ - $this->getMockDiff( [ $this->getMockDiffOp( - 'change', - [ 'd1', 'd2' ], - [ 'a1', 'a2' ] - ) ] ), - [ - [ - 'action' => 'change', - 'old' => 'd1', - 'new' => 'mockLine', - 'newline' => 1, 'oldline' => 1 - ], - [ - 'action' => 'change', - 'old' => 'd2', - 'new' => 'mockLine', - 'newline' => 2, 'oldline' => 2 - ], - ], - ]; - - $testCases = []; - foreach ( $emptyArrayTestCases as $testCase ) { - $testCases[] = [ $testCase, [] ]; - } - foreach ( $otherTestCases as $testCase ) { - $testCases[] = [ $testCase[0], $testCase[1] ]; - } - return $testCases; - } - -} diff --git a/tests/phpunit/includes/diff/DiffOpTest.php b/tests/phpunit/includes/diff/DiffOpTest.php deleted file mode 100644 index 3026fad6bd..0000000000 --- a/tests/phpunit/includes/diff/DiffOpTest.php +++ /dev/null @@ -1,68 +0,0 @@ -type = 'foo'; - $this->assertEquals( 'foo', $obj->getType() ); - } - - /** - * @covers DiffOp::getOrig - */ - public function testGetOrig() { - $obj = new FakeDiffOp(); - $obj->orig = [ 'foo' ]; - $this->assertEquals( [ 'foo' ], $obj->getOrig() ); - } - - /** - * @covers DiffOp::getClosing - */ - public function testGetClosing() { - $obj = new FakeDiffOp(); - $obj->closing = [ 'foo' ]; - $this->assertEquals( [ 'foo' ], $obj->getClosing() ); - } - - /** - * @covers DiffOp::getClosing - */ - public function testGetClosingWithParameter() { - $obj = new FakeDiffOp(); - $obj->closing = [ 'foo', 'bar', 'baz' ]; - $this->assertEquals( 'foo', $obj->getClosing( 0 ) ); - $this->assertEquals( 'bar', $obj->getClosing( 1 ) ); - $this->assertEquals( 'baz', $obj->getClosing( 2 ) ); - $this->assertEquals( null, $obj->getClosing( 3 ) ); - } - - /** - * @covers DiffOp::norig - */ - public function testNorig() { - $obj = new FakeDiffOp(); - $this->assertEquals( 0, $obj->norig() ); - $obj->orig = [ 'foo' ]; - $this->assertEquals( 1, $obj->norig() ); - } - - /** - * @covers DiffOp::nclosing - */ - public function testNclosing() { - $obj = new FakeDiffOp(); - $this->assertEquals( 0, $obj->nclosing() ); - $obj->closing = [ 'foo' ]; - $this->assertEquals( 1, $obj->nclosing() ); - } - -} diff --git a/tests/phpunit/includes/diff/DiffTest.php b/tests/phpunit/includes/diff/DiffTest.php deleted file mode 100644 index da6d7d9544..0000000000 --- a/tests/phpunit/includes/diff/DiffTest.php +++ /dev/null @@ -1,19 +0,0 @@ -edits = 'FooBarBaz'; - $this->assertEquals( 'FooBarBaz', $obj->getEdits() ); - } - -} diff --git a/tests/phpunit/includes/diff/DifferenceEngineSlotDiffRendererTest.php b/tests/phpunit/includes/diff/DifferenceEngineSlotDiffRendererTest.php deleted file mode 100644 index fe129b751a..0000000000 --- a/tests/phpunit/includes/diff/DifferenceEngineSlotDiffRendererTest.php +++ /dev/null @@ -1,44 +0,0 @@ -getDiff( $oldContent, $newContent ); - $this->assertEquals( 'xxx|yyy', $diff ); - - $diff = $slotDiffRenderer->getDiff( null, $newContent ); - $this->assertEquals( '|yyy', $diff ); - - $diff = $slotDiffRenderer->getDiff( $oldContent, null ); - $this->assertEquals( 'xxx|', $diff ); - } - - public function testAddModules() { - $output = $this->getMockBuilder( OutputPage::class ) - ->disableOriginalConstructor() - ->setMethods( [ 'addModules' ] ) - ->getMock(); - $output->expects( $this->once() ) - ->method( 'addModules' ) - ->with( 'foo' ); - $differenceEngine = new CustomDifferenceEngine(); - $slotDiffRenderer = new DifferenceEngineSlotDiffRenderer( $differenceEngine ); - $slotDiffRenderer->addModules( $output ); - } - - public function testGetExtraCacheKeys() { - $differenceEngine = new CustomDifferenceEngine(); - $slotDiffRenderer = new DifferenceEngineSlotDiffRenderer( $differenceEngine ); - $extraCacheKeys = $slotDiffRenderer->getExtraCacheKeys(); - $this->assertSame( [ 'foo' ], $extraCacheKeys ); - } - -} diff --git a/tests/phpunit/includes/diff/SlotDiffRendererTest.php b/tests/phpunit/includes/diff/SlotDiffRendererTest.php deleted file mode 100644 index a03280ddb2..0000000000 --- a/tests/phpunit/includes/diff/SlotDiffRendererTest.php +++ /dev/null @@ -1,78 +0,0 @@ -getMockBuilder( SlotDiffRenderer::class ) - ->getMock(); - try { - // __call needs help deciding which parameter to take by reference - call_user_func_array( [ TestingAccessWrapper::newFromObject( $slotDiffRenderer ), - 'normalizeContents' ], [ &$oldContent, &$newContent, $allowedClasses ] ); - $this->assertEquals( $expectedOldContent, $oldContent ); - $this->assertEquals( $expectedNewContent, $newContent ); - } catch ( Exception $e ) { - if ( !$expectedExceptionClass ) { - throw $e; - } - $this->assertInstanceOf( $expectedExceptionClass, $e ); - } - } - - public function provideNormalizeContents() { - return [ - 'both null' => [ null, null, null, null, null, InvalidArgumentException::class ], - 'left null' => [ - null, new WikitextContent( 'abc' ), null, - new WikitextContent( '' ), new WikitextContent( 'abc' ), null, - ], - 'right null' => [ - new WikitextContent( 'def' ), null, null, - new WikitextContent( 'def' ), new WikitextContent( '' ), null, - ], - 'type filter' => [ - new WikitextContent( 'abc' ), new WikitextContent( 'def' ), WikitextContent::class, - new WikitextContent( 'abc' ), new WikitextContent( 'def' ), null, - ], - 'type filter (subclass)' => [ - new WikitextContent( 'abc' ), new WikitextContent( 'def' ), TextContent::class, - new WikitextContent( 'abc' ), new WikitextContent( 'def' ), null, - ], - 'type filter (null)' => [ - new WikitextContent( 'abc' ), null, TextContent::class, - new WikitextContent( 'abc' ), new WikitextContent( '' ), null, - ], - 'type filter failure (left)' => [ - new TextContent( 'abc' ), new WikitextContent( 'def' ), WikitextContent::class, - null, null, ParameterTypeException::class, - ], - 'type filter failure (right)' => [ - new WikitextContent( 'abc' ), new TextContent( 'def' ), WikitextContent::class, - null, null, ParameterTypeException::class, - ], - 'type filter (array syntax)' => [ - new WikitextContent( 'abc' ), new JsonContent( 'def' ), - [ JsonContent::class, WikitextContent::class ], - new WikitextContent( 'abc' ), new JsonContent( 'def' ), null, - ], - 'type filter failure (array syntax)' => [ - new WikitextContent( 'abc' ), new CssContent( 'def' ), - [ JsonContent::class, WikitextContent::class ], - null, null, ParameterTypeException::class, - ], - ]; - } - -} diff --git a/tests/phpunit/includes/exception/MWExceptionHandlerTest.php b/tests/phpunit/includes/exception/MWExceptionHandlerTest.php deleted file mode 100644 index 6606065660..0000000000 --- a/tests/phpunit/includes/exception/MWExceptionHandlerTest.php +++ /dev/null @@ -1,74 +0,0 @@ -getTrace(); - $hasObject = false; - $hasArray = false; - foreach ( $trace as $frame ) { - if ( !isset( $frame['args'] ) ) { - continue; - } - foreach ( $frame['args'] as $arg ) { - $hasObject = $hasObject || is_object( $arg ); - $hasArray = $hasArray || is_array( $arg ); - } - - if ( $hasObject && $hasArray ) { - break; - } - } - $this->assertTrue( $hasObject, - "The stacktrace must have a function having an object has parameter" ); - $this->assertTrue( $hasArray, - "The stacktrace must have a function having an array has parameter" ); - - # Now we redact the trace.. and make sure no function arguments are - # arrays or objects. - $redacted = MWExceptionHandler::getRedactedTrace( $e ); - - foreach ( $redacted as $frame ) { - if ( !isset( $frame['args'] ) ) { - continue; - } - foreach ( $frame['args'] as $arg ) { - $this->assertNotInternalType( 'array', $arg ); - $this->assertNotInternalType( 'object', $arg ); - } - } - - $this->assertEquals( 'value', $refvar, 'Ensuring reference variable wasn\'t changed' ); - } - - /** - * Helper function for testExpandArgumentsInCall - * - * Pass it an object and an array, and something by reference :-) - * - * @throws Exception - */ - protected static function helperThrowAnException( $a, $b, &$c ) { - throw new Exception(); - } -} diff --git a/tests/phpunit/includes/externalstore/ExternalStoreAccessTest.php b/tests/phpunit/includes/externalstore/ExternalStoreAccessTest.php new file mode 100644 index 0000000000..80e836fa58 --- /dev/null +++ b/tests/phpunit/includes/externalstore/ExternalStoreAccessTest.php @@ -0,0 +1,100 @@ +assertEquals( false, $access->isReadOnly() ); + + /** @var ExternalStoreMemory $store */ + $store = $esFactory->getStore( 'memory' ); + $this->assertInstanceOf( ExternalStoreMemory::class, $store ); + + $lb = $this->getMockBuilder( LoadBalancer::class ) + ->disableOriginalConstructor()->getMock(); + $lb->expects( $this->any() )->method( 'getReadOnlyReason' )->willReturn( 'Locked' ); + $lb->expects( $this->any() )->method( 'getServerInfo' )->willReturn( [] ); + + $lbFactory = $this->getMockBuilder( LBFactory::class ) + ->disableOriginalConstructor()->getMock(); + $lbFactory->expects( $this->any() )->method( 'getExternalLB' )->willReturn( $lb ); + + $this->setService( 'DBLoadBalancerFactory', $lbFactory ); + + $active = [ 'db', 'mwstore' ]; + $defaults = [ 'DB://clusterX' ]; + $esFactory = new ExternalStoreFactory( $active, $defaults, 'db-prefix' ); + $access = new ExternalStoreAccess( $esFactory ); + $this->assertEquals( true, $access->isReadOnly() ); + + $store->clear(); + } + + /** + * @covers ExternalStoreAccess::fetchFromURL + * @covers ExternalStoreAccess::fetchFromURLs + * @covers ExternalStoreAccess::insert + */ + public function testReadWrite() { + $active = [ 'memory' ]; // active store types + $defaults = [ 'memory://cluster1', 'memory://cluster2' ]; + $esFactory = new ExternalStoreFactory( $active, $defaults, 'db-prefix' ); + $access = new ExternalStoreAccess( $esFactory ); + + /** @var ExternalStoreMemory $storeLocal */ + $storeLocal = $esFactory->getStore( 'memory' ); + /** @var ExternalStoreMemory $storeOther */ + $storeOther = $esFactory->getStore( 'memory', [ 'domain' => 'other' ] ); + $this->assertInstanceOf( ExternalStoreMemory::class, $storeLocal ); + $this->assertInstanceOf( ExternalStoreMemory::class, $storeOther ); + + $v1 = wfRandomString(); + $v2 = wfRandomString(); + $v3 = wfRandomString(); + + $this->assertEquals( false, $storeLocal->fetchFromURL( 'memory://cluster1/1' ) ); + + $url1 = 'memory://cluster1/1'; + $this->assertEquals( + $url1, + $esFactory->getStoreForUrl( 'memory://cluster1' ) + ->store( $esFactory->getStoreLocationFromUrl( 'memory://cluster1' ), $v1 ) + ); + $this->assertEquals( + $v1, + $esFactory->getStoreForUrl( 'memory://cluster1/1' ) + ->fetchFromURL( 'memory://cluster1/1' ) + ); + $this->assertEquals( $v1, $storeLocal->fetchFromURL( 'memory://cluster1/1' ) ); + + $url2 = $access->insert( $v2 ); + $url3 = $access->insert( $v3, [ 'domain' => 'other' ] ); + $this->assertNotFalse( $url2 ); + $this->assertNotFalse( $url3 ); + // There is only one active store type + $this->assertEquals( $v2, $storeLocal->fetchFromURL( $url2 ) ); + $this->assertEquals( $v3, $storeOther->fetchFromURL( $url3 ) ); + $this->assertEquals( false, $storeOther->fetchFromURL( $url2 ) ); + $this->assertEquals( false, $storeLocal->fetchFromURL( $url3 ) ); + + $res = $access->fetchFromURLs( [ $url1, $url2, $url3 ] ); + $this->assertEquals( [ $url1 => $v1, $url2 => $v2, $url3 => false ], $res, "Local-only" ); + + $storeLocal->clear(); + $storeOther->clear(); + } +} diff --git a/tests/phpunit/includes/externalstore/ExternalStoreFactoryTest.php b/tests/phpunit/includes/externalstore/ExternalStoreFactoryTest.php index f762693864..8eaecc6ec4 100644 --- a/tests/phpunit/includes/externalstore/ExternalStoreFactoryTest.php +++ b/tests/phpunit/includes/externalstore/ExternalStoreFactoryTest.php @@ -1,16 +1,29 @@ assertFalse( $factory->getStoreObject( 'ForTesting' ) ); - $this->assertFalse( $factory->getStoreObject( 'foo' ) ); + /** + * @expectedException ExternalStoreException + */ + public function testExternalStoreFactory_noStores1() { + $factory = new ExternalStoreFactory( [], [], 'test-id' ); + $factory->getStore( 'ForTesting' ); + } + + /** + * @expectedException ExternalStoreException + */ + public function testExternalStoreFactory_noStores2() { + $factory = new ExternalStoreFactory( [], [], 'test-id' ); + $factory->getStore( 'foo' ); } public function provideStoreNames() { @@ -24,18 +37,119 @@ class ExternalStoreFactoryTest extends PHPUnit\Framework\TestCase { * @dataProvider provideStoreNames */ public function testExternalStoreFactory_someStore_protoMatch( $proto ) { - $factory = new ExternalStoreFactory( [ 'ForTesting' ] ); - $store = $factory->getStoreObject( $proto ); + $factory = new ExternalStoreFactory( [ 'ForTesting' ], [], 'test-id' ); + $store = $factory->getStore( $proto ); $this->assertInstanceOf( ExternalStoreForTesting::class, $store ); } /** * @dataProvider provideStoreNames + * @expectedException ExternalStoreException */ public function testExternalStoreFactory_someStore_noProtoMatch( $proto ) { - $factory = new ExternalStoreFactory( [ 'SomeOtherClassName' ] ); - $store = $factory->getStoreObject( $proto ); - $this->assertFalse( $store ); + $factory = new ExternalStoreFactory( [ 'SomeOtherClassName' ], [], 'test-id' ); + $factory->getStore( $proto ); + } + + /** + * @covers ExternalStoreFactory::getProtocols + * @covers ExternalStoreFactory::getWriteBaseUrls + * @covers ExternalStoreFactory::getStore + */ + public function testStoreFactoryBasic() { + $active = [ 'memory', 'mwstore' ]; + $defaults = [ 'memory://cluster1', 'memory://cluster2', 'mwstore://memstore1' ]; + $esFactory = new ExternalStoreFactory( $active, $defaults, 'db-prefix' ); + $this->setMwGlobals( 'wgFileBackends', [ + [ + 'name' => 'memstore1', + 'class' => 'MemoryFileBackend', + 'domain' => 'its-all-in-your-head', + 'readOnly' => 'reason is a lie', + 'lockManager' => 'nullLockManager' + ] + ] ); + + $this->assertEquals( $active, $esFactory->getProtocols() ); + $this->assertEquals( $defaults, $esFactory->getWriteBaseUrls() ); + + /** @var ExternalStoreMemory $store */ + $store = $esFactory->getStore( 'memory' ); + $this->assertInstanceOf( ExternalStoreMemory::class, $store ); + $this->assertFalse( $store->isReadOnly( 'cluster1' ), "Location is writable" ); + $this->assertFalse( $store->isReadOnly( 'cluster2' ), "Location is writable" ); + + $mwStore = $esFactory->getStore( 'mwstore' ); + $this->assertTrue( $mwStore->isReadOnly( 'memstore1' ), "Location is read-only" ); + + $lb = $this->getMockBuilder( \Wikimedia\Rdbms\LoadBalancer::class ) + ->disableOriginalConstructor()->getMock(); + $lb->expects( $this->any() )->method( 'getReadOnlyReason' )->willReturn( 'Locked' ); + $lbFactory = $this->getMockBuilder( LBFactory::class ) + ->disableOriginalConstructor()->getMock(); + $lbFactory->expects( $this->any() )->method( 'getExternalLB' )->willReturn( $lb ); + + $this->setService( 'DBLoadBalancerFactory', $lbFactory ); + + $active = [ 'db', 'mwstore' ]; + $defaults = [ 'db://clusterX' ]; + $esFactory = new ExternalStoreFactory( $active, $defaults, 'db-prefix' ); + $this->assertEquals( $active, $esFactory->getProtocols() ); + $this->assertEquals( $defaults, $esFactory->getWriteBaseUrls() ); + + $store->clear(); } + /** + * @covers ExternalStoreFactory::getStoreForUrl + * @covers ExternalStoreFactory::getStoreLocationFromUrl + */ + public function testStoreFactoryReadWrite() { + $active = [ 'memory' ]; // active store types + $defaults = [ 'memory://cluster1', 'memory://cluster2' ]; + $esFactory = new ExternalStoreFactory( $active, $defaults, 'db-prefix' ); + $access = new ExternalStoreAccess( $esFactory ); + + /** @var ExternalStoreMemory $storeLocal */ + $storeLocal = $esFactory->getStore( 'memory' ); + /** @var ExternalStoreMemory $storeOther */ + $storeOther = $esFactory->getStore( 'memory', [ 'domain' => 'other' ] ); + $this->assertInstanceOf( ExternalStoreMemory::class, $storeLocal ); + $this->assertInstanceOf( ExternalStoreMemory::class, $storeOther ); + + $v1 = wfRandomString(); + $v2 = wfRandomString(); + $v3 = wfRandomString(); + + $this->assertEquals( false, $storeLocal->fetchFromURL( 'memory://cluster1/1' ) ); + + $url1 = 'memory://cluster1/1'; + $this->assertEquals( + $url1, + $esFactory->getStoreForUrl( 'memory://cluster1' ) + ->store( $esFactory->getStoreLocationFromUrl( 'memory://cluster1' ), $v1 ) + ); + $this->assertEquals( + $v1, + $esFactory->getStoreForUrl( 'memory://cluster1/1' ) + ->fetchFromURL( 'memory://cluster1/1' ) + ); + $this->assertEquals( $v1, $storeLocal->fetchFromURL( 'memory://cluster1/1' ) ); + + $url2 = $access->insert( $v2 ); + $url3 = $access->insert( $v3, [ 'domain' => 'other' ] ); + $this->assertNotFalse( $url2 ); + $this->assertNotFalse( $url3 ); + // There is only one active store type + $this->assertEquals( $v2, $storeLocal->fetchFromURL( $url2 ) ); + $this->assertEquals( $v3, $storeOther->fetchFromURL( $url3 ) ); + $this->assertEquals( false, $storeOther->fetchFromURL( $url2 ) ); + $this->assertEquals( false, $storeLocal->fetchFromURL( $url3 ) ); + + $res = $access->fetchFromURLs( [ $url1, $url2, $url3 ] ); + $this->assertEquals( [ $url1 => $v1, $url2 => $v2, $url3 => false ], $res, "Local-only" ); + + $storeLocal->clear(); + $storeOther->clear(); + } } diff --git a/tests/phpunit/includes/externalstore/ExternalStoreTest.php b/tests/phpunit/includes/externalstore/ExternalStoreTest.php index 7ca38749fa..60db27da37 100644 --- a/tests/phpunit/includes/externalstore/ExternalStoreTest.php +++ b/tests/phpunit/includes/externalstore/ExternalStoreTest.php @@ -8,7 +8,7 @@ class ExternalStoreTest extends MediaWikiTestCase { public function testExternalFetchFromURL_noExternalStores() { $this->setService( 'ExternalStoreFactory', - new ExternalStoreFactory( [] ) + new ExternalStoreFactory( [], [], 'test-id' ) ); $this->assertFalse( @@ -23,7 +23,7 @@ class ExternalStoreTest extends MediaWikiTestCase { public function testExternalFetchFromURL_someExternalStore() { $this->setService( 'ExternalStoreFactory', - new ExternalStoreFactory( [ 'ForTesting' ] ) + new ExternalStoreFactory( [ 'ForTesting' ], [ 'ForTesting://cluster1' ], 'test-id' ) ); $this->assertEquals( diff --git a/tests/phpunit/includes/filebackend/HTTPFileStreamerTest.php b/tests/phpunit/includes/filebackend/HTTPFileStreamerTest.php new file mode 100644 index 0000000000..bb025b6205 --- /dev/null +++ b/tests/phpunit/includes/filebackend/HTTPFileStreamerTest.php @@ -0,0 +1,36 @@ +assertSame( $expectedRaw, $actualRaw ); + $this->assertSame( $expectedOpt, $actualOpt ); + } + + public function providePreprocessHeaders() { + return [ + [ + [ 'Vary' => 'cookie', 'Cache-Control' => 'private' ], + [ 'Vary: cookie', 'Cache-Control: private' ], + [], + ], + [ + [ + 'Range' => 'bytes=(123-456)', + 'Content-Type' => 'video/mp4', + 'If-Modified-Since' => 'Wed, 21 Oct 2015 07:28:00 GMT', + ], + [ 'Content-Type: video/mp4' ], + [ 'range' => 'bytes=(123-456)', 'if-modified-since' => 'Wed, 21 Oct 2015 07:28:00 GMT' ], + ], + ]; + } + +} diff --git a/tests/phpunit/includes/filerepo/FileBackendDBRepoWrapperTest.php b/tests/phpunit/includes/filerepo/FileBackendDBRepoWrapperTest.php deleted file mode 100644 index 346be7afa3..0000000000 --- a/tests/phpunit/includes/filerepo/FileBackendDBRepoWrapperTest.php +++ /dev/null @@ -1,140 +0,0 @@ -expects( $dbReadsExpected ) - ->method( 'selectField' ) - ->will( $this->returnValue( $dbReturnValue ) ); - - $newPaths = $wrapperMock->getBackendPaths( [ $originalPath ], $latest ); - - $this->assertEquals( - $expectedBackendPath, - $newPaths[0], - $message ); - } - - public function getBackendPathsProvider() { - $prefix = 'mwstore://' . $this->backendName . '/' . $this->repoName; - $mocksForCaching = $this->getMocks(); - - return [ - [ - $mocksForCaching, - false, - $this->once(), - '96246614d75ba1703bdfd5d7660bb57407aaf5d9', - $prefix . '-public/f/o/foobar.jpg', - $prefix . '-original/9/6/2/96246614d75ba1703bdfd5d7660bb57407aaf5d9', - 'Public path translated correctly', - ], - [ - $mocksForCaching, - false, - $this->never(), - '96246614d75ba1703bdfd5d7660bb57407aaf5d9', - $prefix . '-public/f/o/foobar.jpg', - $prefix . '-original/9/6/2/96246614d75ba1703bdfd5d7660bb57407aaf5d9', - 'LRU cache leveraged', - ], - [ - $this->getMocks(), - true, - $this->once(), - '96246614d75ba1703bdfd5d7660bb57407aaf5d9', - $prefix . '-public/f/o/foobar.jpg', - $prefix . '-original/9/6/2/96246614d75ba1703bdfd5d7660bb57407aaf5d9', - 'Latest obtained', - ], - [ - $this->getMocks(), - true, - $this->never(), - '96246614d75ba1703bdfd5d7660bb57407aaf5d9', - $prefix . '-deleted/f/o/foobar.jpg', - $prefix . '-original/f/o/o/foobar', - 'Deleted path translated correctly', - ], - [ - $this->getMocks(), - true, - $this->once(), - null, - $prefix . '-public/b/a/baz.jpg', - $prefix . '-public/b/a/baz.jpg', - 'Path left untouched if no sha1 can be found', - ], - ]; - } - - /** - * @covers FileBackendDBRepoWrapper::getFileContentsMulti - */ - public function testGetFileContentsMulti() { - list( $dbMock, $backendMock, $wrapperMock ) = $this->getMocks(); - - $sha1Path = 'mwstore://' . $this->backendName . '/' . $this->repoName - . '-original/9/6/2/96246614d75ba1703bdfd5d7660bb57407aaf5d9'; - $filenamePath = 'mwstore://' . $this->backendName . '/' . $this->repoName - . '-public/f/o/foobar.jpg'; - - $dbMock->expects( $this->once() ) - ->method( 'selectField' ) - ->will( $this->returnValue( '96246614d75ba1703bdfd5d7660bb57407aaf5d9' ) ); - - $backendMock->expects( $this->once() ) - ->method( 'getFileContentsMulti' ) - ->will( $this->returnValue( [ $sha1Path => 'foo' ] ) ); - - $result = $wrapperMock->getFileContentsMulti( [ 'srcs' => [ $filenamePath ] ] ); - - $this->assertEquals( - [ $filenamePath => 'foo' ], - $result, - 'File contents paths translated properly' - ); - } - - protected function getMocks() { - $dbMock = $this->getMockBuilder( Wikimedia\Rdbms\IDatabase::class ) - ->disableOriginalClone() - ->disableOriginalConstructor() - ->getMock(); - - $backendMock = $this->getMockBuilder( FSFileBackend::class ) - ->setConstructorArgs( [ [ - 'name' => $this->backendName, - 'wikiId' => wfWikiID() - ] ] ) - ->getMock(); - - $wrapperMock = $this->getMockBuilder( FileBackendDBRepoWrapper::class ) - ->setMethods( [ 'getDB' ] ) - ->setConstructorArgs( [ [ - 'backend' => $backendMock, - 'repoName' => $this->repoName, - 'dbHandleFactory' => null - ] ] ) - ->getMock(); - - $wrapperMock->expects( $this->any() )->method( 'getDB' )->will( $this->returnValue( $dbMock ) ); - - return [ $dbMock, $backendMock, $wrapperMock ]; - } -} diff --git a/tests/phpunit/includes/htmlform/HTMLCheckMatrixTest.php b/tests/phpunit/includes/htmlform/HTMLCheckMatrixTest.php deleted file mode 100644 index 05c567df75..0000000000 --- a/tests/phpunit/includes/htmlform/HTMLCheckMatrixTest.php +++ /dev/null @@ -1,104 +0,0 @@ - [ 'r1', 'r2' ], - 'columns' => [ 'c1', 'c2' ], - 'fieldname' => 'test', - ]; - - public function testPlainInstantiation() { - try { - new HTMLCheckMatrix( [] ); - } catch ( MWException $e ) { - $this->assertInstanceOf( HTMLFormFieldRequiredOptionsException::class, $e ); - return; - } - - $this->fail( 'Expected MWException indicating missing parameters but none was thrown.' ); - } - - public function testInstantiationWithMinimumRequiredParameters() { - new HTMLCheckMatrix( self::$defaultOptions ); - $this->assertTrue( true ); // form instantiation must throw exception on failure - } - - public function testValidateCallsUserDefinedValidationCallback() { - $called = false; - $field = new HTMLCheckMatrix( self::$defaultOptions + [ - 'validation-callback' => function () use ( &$called ) { - $called = true; - - return false; - }, - ] ); - $this->assertEquals( false, $this->validate( $field, [] ) ); - $this->assertTrue( $called ); - } - - public function testValidateRequiresArrayInput() { - $field = new HTMLCheckMatrix( self::$defaultOptions ); - $this->assertEquals( false, $this->validate( $field, null ) ); - $this->assertEquals( false, $this->validate( $field, true ) ); - $this->assertEquals( false, $this->validate( $field, 'abc' ) ); - $this->assertEquals( false, $this->validate( $field, new stdClass ) ); - $this->assertEquals( true, $this->validate( $field, [] ) ); - } - - public function testValidateAllowsOnlyKnownTags() { - $field = new HTMLCheckMatrix( self::$defaultOptions ); - $this->assertInstanceOf( Message::class, $this->validate( $field, [ 'foo' ] ) ); - } - - public function testValidateAcceptsPartialTagList() { - $field = new HTMLCheckMatrix( self::$defaultOptions ); - $this->assertTrue( $this->validate( $field, [] ) ); - $this->assertTrue( $this->validate( $field, [ 'c1-r1' ] ) ); - $this->assertTrue( $this->validate( $field, [ 'c1-r1', 'c1-r2', 'c2-r1', 'c2-r2' ] ) ); - } - - /** - * This form object actually has no visibility into what happens later on, but essentially - * if the data submitted by the user passes validate the following is run: - * foreach ( $field->filterDataForSubmit( $data ) as $k => $v ) { - * $user->setOption( $k, $v ); - * } - */ - public function testValuesForcedOnRemainOn() { - $field = new HTMLCheckMatrix( self::$defaultOptions + [ - 'force-options-on' => [ 'c2-r1' ], - ] ); - $expected = [ - 'c1-r1' => false, - 'c1-r2' => false, - 'c2-r1' => true, - 'c2-r2' => false, - ]; - $this->assertEquals( $expected, $field->filterDataForSubmit( [] ) ); - } - - public function testValuesForcedOffRemainOff() { - $field = new HTMLCheckMatrix( self::$defaultOptions + [ - 'force-options-off' => [ 'c1-r2', 'c2-r2' ], - ] ); - $expected = [ - 'c1-r1' => true, - 'c1-r2' => false, - 'c2-r1' => true, - 'c2-r2' => false, - ]; - // array_keys on the result simulates submitting all fields checked - $this->assertEquals( $expected, $field->filterDataForSubmit( array_keys( $expected ) ) ); - } - - protected function validate( HTMLFormField $field, $submitted ) { - return $field->validate( - $submitted, - [ self::$defaultOptions['fieldname'] => $submitted ] - ); - } - -} diff --git a/tests/phpunit/includes/http/HttpTest.php b/tests/phpunit/includes/http/HttpTest.php index 09bcfc9adf..ef499a1ee7 100644 --- a/tests/phpunit/includes/http/HttpTest.php +++ b/tests/phpunit/includes/http/HttpTest.php @@ -7,20 +7,6 @@ */ class HttpTest extends MediaWikiTestCase { - /** - * Test Http::isValidURI() - * T29854 : Http::isValidURI is too lax - * @dataProvider provideURI - * @covers Http::isValidURI - */ - public function testIsValidUri( $expect, $URI, $message = '' ) { - $this->assertEquals( - $expect, - (bool)Http::isValidURI( $URI ), - $message - ); - } - /** * @covers Http::getProxy */ @@ -41,71 +27,4 @@ class HttpTest extends MediaWikiTestCase { ); } - /** - * Feeds URI to test a long regular expression in Http::isValidURI - */ - public static function provideURI() { - /** Format: 'boolean expectation', 'URI to test', 'Optional message' */ - return [ - [ false, '¿non sens before!! http://a', 'Allow anything before URI' ], - - # (http|https) - only two schemes allowed - [ true, 'http://www.example.org/' ], - [ true, 'https://www.example.org/' ], - [ true, 'http://www.example.org', 'URI without directory' ], - [ true, 'http://a', 'Short name' ], - [ true, 'http://étoile', 'Allow UTF-8 in hostname' ], # 'étoile' is french for 'star' - [ false, '\\host\directory', 'CIFS share' ], - [ false, 'gopher://host/dir', 'Reject gopher scheme' ], - [ false, 'telnet://host', 'Reject telnet scheme' ], - - # :\/\/ - double slashes - [ false, 'http//example.org', 'Reject missing colon in protocol' ], - [ false, 'http:/example.org', 'Reject missing slash in protocol' ], - [ false, 'http:example.org', 'Must have two slashes' ], - # Following fail since hostname can be made of anything - [ false, 'http:///example.org', 'Must have exactly two slashes, not three' ], - - # (\w+:{0,1}\w*@)? - optional user:pass - [ true, 'http://user@host', 'Username provided' ], - [ true, 'http://user:@host', 'Username provided, no password' ], - [ true, 'http://user:pass@host', 'Username and password provided' ], - - # (\S+) - host part is made of anything not whitespaces - // commented these out in order to remove @group Broken - // @todo are these valid tests? if so, fix Http::isValidURI so it can handle them - // [ false, 'http://!"èèè¿¿¿~~\'', 'hostname is made of any non whitespace' ], - // [ false, 'http://exam:ple.org/', 'hostname can not use colons!' ], - - # (:[0-9]+)? - port number - [ true, 'http://example.org:80/' ], - [ true, 'https://example.org:80/' ], - [ true, 'http://example.org:443/' ], - [ true, 'https://example.org:443/' ], - - # Part after the hostname is / or / with something else - [ true, 'http://example/#' ], - [ true, 'http://example/!' ], - [ true, 'http://example/:' ], - [ true, 'http://example/.' ], - [ true, 'http://example/?' ], - [ true, 'http://example/+' ], - [ true, 'http://example/=' ], - [ true, 'http://example/&' ], - [ true, 'http://example/%' ], - [ true, 'http://example/@' ], - [ true, 'http://example/-' ], - [ true, 'http://example//' ], - [ true, 'http://example/&' ], - - # Fragment - [ true, 'http://exam#ple.org', ], # This one is valid, really! - [ true, 'http://example.org:80#anchor' ], - [ true, 'http://example.org/?id#anchor' ], - [ true, 'http://example.org/?#anchor' ], - - [ false, 'http://a ¿non !!sens after', 'Allow anything after URI' ], - ]; - } - } diff --git a/tests/phpunit/includes/import/ImportTest.php b/tests/phpunit/includes/import/ImportTest.php index 80238ec86d..0132efc53f 100644 --- a/tests/phpunit/includes/import/ImportTest.php +++ b/tests/phpunit/includes/import/ImportTest.php @@ -11,10 +11,6 @@ use MediaWiki\MediaWikiServices; */ class ImportTest extends MediaWikiLangTestCase { - private function getDataSource( $xml ) { - return new ImportStringSource( $xml ); - } - /** * @covers WikiImporter * @dataProvider getUnknownTagsXML @@ -23,7 +19,7 @@ class ImportTest extends MediaWikiLangTestCase { * @param string $title */ public function testUnknownXMLTags( $xml, $text, $title ) { - $source = $this->getDataSource( $xml ); + $source = new ImportStringSource( $xml ); $importer = new WikiImporter( $source, @@ -82,7 +78,7 @@ EOF * @param string|null $redirectTitle */ public function testHandlePageContainsRedirect( $xml, $redirectTitle ) { - $source = $this->getDataSource( $xml ); + $source = new ImportStringSource( $xml ); $redirect = null; $callback = function ( Title $title, ForeignTitle $foreignTitle, $revCount, @@ -168,7 +164,7 @@ EOF * @param array|null $namespaces */ public function testSiteInfoContainsNamespaces( $xml, $namespaces ) { - $source = $this->getDataSource( $xml ); + $source = new ImportStringSource( $xml ); $importNamespaces = null; $callback = function ( array $siteinfo, $innerImporter ) use ( &$importNamespaces ) { @@ -253,7 +249,7 @@ EOF $n = ( $assign ? 1 : 0 ) + ( $create ? 2 : 0 ); // phpcs:disable Generic.Files.LineLength - $source = $this->getDataSource( << TestImportPage diff --git a/tests/phpunit/includes/installer/InstallDocFormatterTest.php b/tests/phpunit/includes/installer/InstallDocFormatterTest.php deleted file mode 100644 index 9584d4b8c4..0000000000 --- a/tests/phpunit/includes/installer/InstallDocFormatterTest.php +++ /dev/null @@ -1,83 +0,0 @@ -assertEquals( - $expected, - InstallDocFormatter::format( $unformattedText ), - $message - ); - } - - /** - * Provider for testFormat() - */ - public static function provideDocFormattingTests() { - # Format: (expected string, unformattedText string, optional message) - return [ - # Escape some wikitext - [ 'Install <tag>', 'Install ', 'Escaping <' ], - [ 'Install {{template}}', 'Install {{template}}', 'Escaping [[' ], - [ 'Install [[page]]', 'Install [[page]]', 'Escaping {{' ], - [ 'Install __TOC__', 'Install __TOC__', 'Escaping __' ], - [ 'Install ', "Install \r", 'Removing \r' ], - - # Transform \t{1,2} into :{1,2} - [ ':One indentation', "\tOne indentation", 'Replacing a single \t' ], - [ '::Two indentations', "\t\tTwo indentations", 'Replacing 2 x \t' ], - - # Transform 'T123' links - [ - '[https://phabricator.wikimedia.org/T123 T123]', - 'T123', 'Testing T123 links' ], - [ - 'bug [https://phabricator.wikimedia.org/T123 T123]', - 'bug T123', 'Testing bug T123 links' ], - [ - '([https://phabricator.wikimedia.org/T987654 T987654])', - '(T987654)', 'Testing (T987654) links' ], - - # "Tabc" shouldn't work - [ 'Tfoobar', 'Tfoobar', "Don't match T followed by non-digits" ], - [ 'T!!fakefake!!', 'T!!fakefake!!', "Don't match T followed by non-digits" ], - - # Transform 'bug 123' links - [ - '[https://bugzilla.wikimedia.org/123 bug 123]', - 'bug 123', 'Testing bug 123 links' ], - [ - '([https://bugzilla.wikimedia.org/987654 bug 987654])', - '(bug 987654)', 'Testing (bug 987654) links' ], - - # "bug abc" shouldn't work - [ 'bug foobar', 'bug foobar', "Don't match bug followed by non-digits" ], - [ 'bug !!fakefake!!', 'bug !!fakefake!!', "Don't match bug followed by non-digits" ], - - # Transform '$wgFooBar' links - [ - '' - . '[https://www.mediawiki.org/wiki/Manual:$wgFooBar $wgFooBar]', - '$wgFooBar', 'Testing basic $wgFooBar' ], - [ - '' - . '[https://www.mediawiki.org/wiki/Manual:$wgFooBar45 $wgFooBar45]', - '$wgFooBar45', 'Testing $wgFooBar45 (with numbers)' ], - [ - '' - . '[https://www.mediawiki.org/wiki/Manual:$wgFoo_Bar $wgFoo_Bar]', - '$wgFoo_Bar', 'Testing $wgFoo_Bar (with underscore)' ], - - # Icky variables that shouldn't link - [ - '$myAwesomeVariable', - '$myAwesomeVariable', - 'Testing $myAwesomeVariable (not starting with $wg)' - ], - [ '$()not!a&Var', '$()not!a&Var', 'Testing $()not!a&Var (obviously not a variable)' ], - ]; - } -} diff --git a/tests/phpunit/includes/installer/OracleInstallerTest.php b/tests/phpunit/includes/installer/OracleInstallerTest.php deleted file mode 100644 index e255089de4..0000000000 --- a/tests/phpunit/includes/installer/OracleInstallerTest.php +++ /dev/null @@ -1,49 +0,0 @@ -assertEquals( $expected, - OracleInstaller::checkConnectStringFormat( $connectString ), - $msg - ); - } - - /** - * Provider to test OracleInstaller::checkConnectStringFormat() - */ - function provideOracleConnectStrings() { - // expected result, connectString[, message] - return [ - [ true, 'simple_01', 'Simple TNS name' ], - [ true, 'simple_01.world', 'TNS name with domain' ], - [ true, 'simple_01.domain.net', 'TNS name with domain' ], - [ true, 'host123', 'Host only' ], - [ true, 'host123.domain.net', 'FQDN only' ], - [ true, '//host123.domain.net', 'FQDN URL only' ], - [ true, '123.223.213.132', 'Host IP only' ], - [ true, 'host:1521', 'Host and port' ], - [ true, 'host:1521/service', 'Host, port and service' ], - [ true, 'host:1521/service:shared', 'Host, port, service and shared server type' ], - [ true, 'host:1521/service:dedicated', 'Host, port, service and dedicated server type' ], - [ true, 'host:1521/service:pooled', 'Host, port, service and pooled server type' ], - [ - true, - 'host:1521/service:shared/instance1', - 'Host, port, service, server type and instance' - ], - [ true, 'host:1521//instance1', 'Host, port and instance' ], - ]; - } - -} diff --git a/tests/phpunit/includes/interwiki/InterwikiLookupAdapterTest.php b/tests/phpunit/includes/interwiki/InterwikiLookupAdapterTest.php deleted file mode 100644 index 0a13de1d96..0000000000 --- a/tests/phpunit/includes/interwiki/InterwikiLookupAdapterTest.php +++ /dev/null @@ -1,133 +0,0 @@ -interwikiLookup = new InterwikiLookupAdapter( - $this->getSiteLookup( $this->getSites() ) - ); - } - - public function testIsValidInterwiki() { - $this->assertTrue( - $this->interwikiLookup->isValidInterwiki( 'enwt' ), - 'enwt known prefix is valid' - ); - $this->assertTrue( - $this->interwikiLookup->isValidInterwiki( 'foo' ), - 'foo site known prefix is valid' - ); - $this->assertFalse( - $this->interwikiLookup->isValidInterwiki( 'xyz' ), - 'unknown prefix is not valid' - ); - } - - public function testFetch() { - $interwiki = $this->interwikiLookup->fetch( '' ); - $this->assertNull( $interwiki ); - - $interwiki = $this->interwikiLookup->fetch( 'xyz' ); - $this->assertFalse( $interwiki ); - - $interwiki = $this->interwikiLookup->fetch( 'foo' ); - $this->assertInstanceOf( Interwiki::class, $interwiki ); - $this->assertSame( 'foobar', $interwiki->getWikiID() ); - - $interwiki = $this->interwikiLookup->fetch( 'enwt' ); - $this->assertInstanceOf( Interwiki::class, $interwiki ); - - $this->assertSame( 'https://en.wiktionary.org/wiki/$1', $interwiki->getURL(), 'getURL' ); - $this->assertSame( 'https://en.wiktionary.org/w/api.php', $interwiki->getAPI(), 'getAPI' ); - $this->assertSame( 'enwiktionary', $interwiki->getWikiID(), 'getWikiID' ); - $this->assertTrue( $interwiki->isLocal(), 'isLocal' ); - } - - public function testGetAllPrefixes() { - $foo = [ - 'iw_prefix' => 'foo', - 'iw_url' => '', - 'iw_api' => '', - 'iw_wikiid' => 'foobar', - 'iw_local' => false, - 'iw_trans' => false, - ]; - $enwt = [ - 'iw_prefix' => 'enwt', - 'iw_url' => 'https://en.wiktionary.org/wiki/$1', - 'iw_api' => 'https://en.wiktionary.org/w/api.php', - 'iw_wikiid' => 'enwiktionary', - 'iw_local' => true, - 'iw_trans' => false, - ]; - - $this->assertEquals( - [ $foo, $enwt ], - $this->interwikiLookup->getAllPrefixes(), - 'getAllPrefixes()' - ); - - $this->assertEquals( - [ $foo ], - $this->interwikiLookup->getAllPrefixes( false ), - 'get external prefixes' - ); - - $this->assertEquals( - [ $enwt ], - $this->interwikiLookup->getAllPrefixes( true ), - 'get local prefixes' - ); - } - - private function getSiteLookup( SiteList $sites ) { - $siteLookup = $this->getMockBuilder( SiteLookup::class ) - ->disableOriginalConstructor() - ->getMock(); - - $siteLookup->expects( $this->any() ) - ->method( 'getSites' ) - ->will( $this->returnValue( $sites ) ); - - return $siteLookup; - } - - private function getSites() { - $sites = []; - - $site = new Site(); - $site->setGlobalId( 'foobar' ); - $site->addInterwikiId( 'foo' ); - $site->setSource( 'external' ); - $sites[] = $site; - - $site = new MediaWikiSite(); - $site->setGlobalId( 'enwiktionary' ); - $site->setGroup( 'wiktionary' ); - $site->setLanguageCode( 'en' ); - $site->addNavigationId( 'enwiktionary' ); - $site->addInterwikiId( 'enwt' ); - $site->setSource( 'local' ); - $site->setPath( MediaWikiSite::PATH_PAGE, "https://en.wiktionary.org/wiki/$1" ); - $site->setPath( MediaWikiSite::PATH_FILE, "https://en.wiktionary.org/w/$1" ); - $sites[] = $site; - - return new SiteList( $sites ); - } - -} diff --git a/tests/phpunit/includes/jobqueue/JobQueueTest.php b/tests/phpunit/includes/jobqueue/JobQueueTest.php index ce07f78bab..8f8dde5559 100644 --- a/tests/phpunit/includes/jobqueue/JobQueueTest.php +++ b/tests/phpunit/includes/jobqueue/JobQueueTest.php @@ -48,7 +48,7 @@ class JobQueueTest extends MediaWikiTestCase { } catch ( MWException $e ) { // unsupported? // @todo What if it was another error? - }; + } } } diff --git a/tests/phpunit/includes/jobqueue/jobs/RefreshLinksJobTest.php b/tests/phpunit/includes/jobqueue/jobs/RefreshLinksJobTest.php index 50d5177d88..24ec2e40fd 100644 --- a/tests/phpunit/includes/jobqueue/jobs/RefreshLinksJobTest.php +++ b/tests/phpunit/includes/jobqueue/jobs/RefreshLinksJobTest.php @@ -69,15 +69,6 @@ class RefreshLinksJobTest extends MediaWikiTestCase { $job = new RefreshLinksJob( $page->getTitle(), [ 'parseThreshold' => 0 ] ); $job->run(); - // assert state - $options = ParserOptions::newCanonical( 'canonical' ); - $out = $parserCache->get( $page, $options ); - $this->assertNotFalse( $out, 'parser cache entry' ); - - $text = $out->getText(); - $this->assertContains( 'MAIN', $text ); - $this->assertContains( 'AUX', $text ); - $this->assertSelect( 'pagelinks', 'pl_title', @@ -92,4 +83,60 @@ class RefreshLinksJobTest extends MediaWikiTestCase { ); } + public function testRunForMultiPage() { + MediaWikiServices::getInstance()->getSlotRoleRegistry()->defineRoleWithModel( + 'aux', + CONTENT_MODEL_WIKITEXT + ); + + $fname = __METHOD__; + + $mainContent = new WikitextContent( 'MAIN [[Kittens]]' ); + $auxContent = new WikitextContent( 'AUX [[Category:Goats]]' ); + $page1 = $this->createPage( "$fname-1", [ 'main' => $mainContent, 'aux' => $auxContent ] ); + + $mainContent = new WikitextContent( 'MAIN [[Dogs]]' ); + $auxContent = new WikitextContent( 'AUX [[Category:Hamsters]]' ); + $page2 = $this->createPage( "$fname-2", [ 'main' => $mainContent, 'aux' => $auxContent ] ); + + // clear state + $parserCache = MediaWikiServices::getInstance()->getParserCache(); + $parserCache->deleteOptionsKey( $page1 ); + $parserCache->deleteOptionsKey( $page2 ); + + $this->db->delete( 'pagelinks', '*', __METHOD__ ); + $this->db->delete( 'categorylinks', '*', __METHOD__ ); + + // run job + $job = new RefreshLinksJob( + Title::newMainPage(), + [ 'pages' => [ [ 0, "$fname-1" ], [ 0, "$fname-2" ] ] ] + ); + $job->run(); + + $this->assertSelect( + 'pagelinks', + 'pl_title', + [ 'pl_from' => $page1->getId() ], + [ [ 'Kittens' ] ] + ); + $this->assertSelect( + 'categorylinks', + 'cl_to', + [ 'cl_from' => $page1->getId() ], + [ [ 'Goats' ] ] + ); + $this->assertSelect( + 'pagelinks', + 'pl_title', + [ 'pl_from' => $page2->getId() ], + [ [ 'Dogs' ] ] + ); + $this->assertSelect( + 'categorylinks', + 'cl_to', + [ 'cl_from' => $page2->getId() ], + [ [ 'Hamsters' ] ] + ); + } } diff --git a/tests/phpunit/includes/json/FormatJsonTest.php b/tests/phpunit/includes/json/FormatJsonTest.php deleted file mode 100644 index a6adf343d5..0000000000 --- a/tests/phpunit/includes/json/FormatJsonTest.php +++ /dev/null @@ -1,436 +0,0 @@ - new stdClass, - 'emptyArray' => [], - 'string' => 'foobar\\', - 'filledArray' => [ - [ - 123, - 456, - ], - // Nested json works without problems - '"7":["8",{"9":"10"}]', - // Whitespace clean up doesn't touch strings that look alike - "{\n\t\"emptyObject\": {\n\t},\n\t\"emptyArray\": [ ]\n}", - ], - ]; - - // No trailing whitespace, no trailing linefeed - $json = '{ - "emptyObject": {}, - "emptyArray": [], - "string": "foobar\\\\", - "filledArray": [ - [ - 123, - 456 - ], - "\"7\":[\"8\",{\"9\":\"10\"}]", - "{\n\t\"emptyObject\": {\n\t},\n\t\"emptyArray\": [ ]\n}" - ] -}'; - - $json = str_replace( "\r", '', $json ); // Windows compat - $json = str_replace( "\t", $expectedIndent, $json ); - $this->assertSame( $json, FormatJson::encode( $obj, $pretty ) ); - } - - public static function provideEncodeDefault() { - return self::getEncodeTestCases( [] ); - } - - /** - * @dataProvider provideEncodeDefault - */ - public function testEncodeDefault( $from, $to ) { - $this->assertSame( $to, FormatJson::encode( $from ) ); - } - - public static function provideEncodeUtf8() { - return self::getEncodeTestCases( [ 'unicode' ] ); - } - - /** - * @dataProvider provideEncodeUtf8 - */ - public function testEncodeUtf8( $from, $to ) { - $this->assertSame( $to, FormatJson::encode( $from, false, FormatJson::UTF8_OK ) ); - } - - public static function provideEncodeXmlMeta() { - return self::getEncodeTestCases( [ 'xmlmeta' ] ); - } - - /** - * @dataProvider provideEncodeXmlMeta - */ - public function testEncodeXmlMeta( $from, $to ) { - $this->assertSame( $to, FormatJson::encode( $from, false, FormatJson::XMLMETA_OK ) ); - } - - public static function provideEncodeAllOk() { - return self::getEncodeTestCases( [ 'unicode', 'xmlmeta' ] ); - } - - /** - * @dataProvider provideEncodeAllOk - */ - public function testEncodeAllOk( $from, $to ) { - $this->assertSame( $to, FormatJson::encode( $from, false, FormatJson::ALL_OK ) ); - } - - public function testEncodePhpBug46944() { - $this->assertNotEquals( - '\ud840\udc00', - strtolower( FormatJson::encode( "\xf0\xa0\x80\x80" ) ), - 'Test encoding an broken json_encode character (U+20000)' - ); - } - - public function testEncodeFail() { - // Set up a recursive object that can't be encoded. - $a = new stdClass; - $b = new stdClass; - $a->b = $b; - $b->a = $a; - $this->assertFalse( FormatJson::encode( $a ) ); - } - - public function testDecodeReturnType() { - $this->assertInternalType( - 'object', - FormatJson::decode( '{"Name": "Cheeso", "Rank": 7}' ), - 'Default to object' - ); - - $this->assertInternalType( - 'array', - FormatJson::decode( '{"Name": "Cheeso", "Rank": 7}', true ), - 'Optional array' - ); - } - - public static function provideParse() { - return [ - [ null ], - [ true ], - [ false ], - [ 0 ], - [ 1 ], - [ 1.2 ], - [ '' ], - [ 'str' ], - [ [ 0, 1, 2 ] ], - [ [ 'a' => 'b' ] ], - [ [ 'a' => 'b' ] ], - [ [ 'a' => 'b', 'x' => [ 'c' => 'd' ] ] ], - ]; - } - - /** - * Recursively convert arrays into stdClass - * @param array|string|bool|int|float|null $value - * @return stdClass|string|bool|int|float|null - */ - public static function toObject( $value ) { - return !is_array( $value ) ? $value : (object)array_map( __METHOD__, $value ); - } - - /** - * @dataProvider provideParse - * @param mixed $value - */ - public function testParse( $value ) { - $expected = self::toObject( $value ); - $json = FormatJson::encode( $expected, false, FormatJson::ALL_OK ); - $this->assertJson( $json ); - - $st = FormatJson::parse( $json ); - $this->assertInstanceOf( Status::class, $st ); - $this->assertTrue( $st->isGood() ); - $this->assertEquals( $expected, $st->getValue() ); - - $st = FormatJson::parse( $json, FormatJson::FORCE_ASSOC ); - $this->assertInstanceOf( Status::class, $st ); - $this->assertTrue( $st->isGood() ); - $this->assertEquals( $value, $st->getValue() ); - } - - /** - * Test data for testParseTryFixing. - * - * Some PHP interpreters use json-c rather than the JSON.org canonical - * parser to avoid being encumbered by the "shall be used for Good, not - * Evil" clause of the JSON.org parser's license. By default, json-c - * parses in a non-strict mode which allows trailing commas for array and - * object delarations among other things, so our JSON_ERROR_SYNTAX rescue - * block is not always triggered. It however isn't lenient in exactly the - * same ways as our TRY_FIXING mode, so the assertions in this test are - * a bit more complicated than they ideally would be: - * - * Optional third argument: true if json-c parses the value without - * intervention, false otherwise. Defaults to true. - * - * Optional fourth argument: expected cannonical JSON serialization of - * json-c parsed result. Defaults to the second argument's value. - */ - public static function provideParseTryFixing() { - return [ - [ "[,]", '[]', false ], - [ "[ , ]", '[]', false ], - [ "[ , }", false ], - [ '[1],', false, true, '[1]' ], - [ "[1,]", '[1]' ], - [ "[1\n,]", '[1]' ], - [ "[1,\n]", '[1]' ], - [ "[1,]\n", '[1]' ], - [ "[1\n,\n]\n", '[1]' ], - [ '["a,",]', '["a,"]' ], - [ "[[1,]\n,[2,\n],[3\n,]]", '[[1],[2],[3]]' ], - // I wish we could parse this, but would need quote parsing - [ '[[1,],[2,],[3,]]', false, true, '[[1],[2],[3]]' ], - [ '[1,,]', false, false, '[1]' ], - ]; - } - - /** - * @dataProvider provideParseTryFixing - * @param string $value - * @param string|bool $expected Expected result with strict parser - * @param bool $jsoncParses Will json-c parse this value without TRY_FIXING? - * @param string|bool $expectedJsonc Expected result with lenient parser - * if different from the strict expectation - */ - public function testParseTryFixing( - $value, $expected, - $jsoncParses = true, $expectedJsonc = null - ) { - // PHP5 results are always expected to have isGood() === false - $expectedGoodStatus = false; - - // Check to see if json parser allows trailing commas - if ( json_decode( '[1,]' ) !== null ) { - // Use json-c specific expected result if provided - $expected = ( $expectedJsonc === null ) ? $expected : $expectedJsonc; - // If json-c parses the value natively, expect isGood() === true - $expectedGoodStatus = $jsoncParses; - } - - $st = FormatJson::parse( $value, FormatJson::TRY_FIXING ); - $this->assertInstanceOf( Status::class, $st ); - if ( $expected === false ) { - $this->assertFalse( $st->isOK(), 'Expected isOK() == false' ); - } else { - $this->assertSame( $expectedGoodStatus, $st->isGood(), - 'Expected isGood() == ' . ( $expectedGoodStatus ? 'true' : 'false' ) - ); - $this->assertTrue( $st->isOK(), 'Expected isOK == true' ); - $val = FormatJson::encode( $st->getValue(), false, FormatJson::ALL_OK ); - $this->assertEquals( $expected, $val ); - } - } - - public static function provideParseErrors() { - return [ - [ 'aaa' ], - [ '{"j": 1 ] }' ], - ]; - } - - /** - * @dataProvider provideParseErrors - * @param mixed $value - */ - public function testParseErrors( $value ) { - $st = FormatJson::parse( $value ); - $this->assertInstanceOf( Status::class, $st ); - $this->assertFalse( $st->isOK() ); - } - - public function provideStripComments() { - return [ - [ '{"a":"b"}', '{"a":"b"}' ], - [ "{\"a\":\"b\"}\n", "{\"a\":\"b\"}\n" ], - [ '/*c*/{"c":"b"}', '{"c":"b"}' ], - [ '{"a":"c"}/*c*/', '{"a":"c"}' ], - [ '/*c//d*/{"c":"b"}', '{"c":"b"}' ], - [ '{/*c*/"c":"b"}', '{"c":"b"}' ], - [ "/*\nc\r\n*/{\"c\":\"b\"}", '{"c":"b"}' ], - [ "//c\n{\"c\":\"b\"}", '{"c":"b"}' ], - [ "//c\r\n{\"c\":\"b\"}", '{"c":"b"}' ], - [ '{"a":"c"}//c', '{"a":"c"}' ], - [ "{\"a-c\"://c\n\"b\"}", '{"a-c":"b"}' ], - [ '{"/*a":"b"}', '{"/*a":"b"}' ], - [ '{"a":"//b"}', '{"a":"//b"}' ], - [ '{"a":"b/*c*/"}', '{"a":"b/*c*/"}' ], - [ "{\"\\\"/*a\":\"b\"}", "{\"\\\"/*a\":\"b\"}" ], - [ '', '' ], - [ '/*c', '' ], - [ '//c', '' ], - [ '"http://example.com"', '"http://example.com"' ], - [ "\0", "\0" ], - [ '"BlÃ¥bærsyltetøy"', '"BlÃ¥bærsyltetøy"' ], - ]; - } - - /** - * @covers FormatJson::stripComments - * @dataProvider provideStripComments - * @param string $json - * @param string $expect - */ - public function testStripComments( $json, $expect ) { - $this->assertSame( $expect, FormatJson::stripComments( $json ) ); - } - - public function provideParseStripComments() { - return [ - [ '/* blah */true', true ], - [ "// blah \ntrue", true ], - [ '[ "a" , /* blah */ "b" ]', [ 'a', 'b' ] ], - ]; - } - - /** - * @covers FormatJson::parse - * @covers FormatJson::stripComments - * @dataProvider provideParseStripComments - * @param string $json - * @param mixed $expect - */ - public function testParseStripComments( $json, $expect ) { - $st = FormatJson::parse( $json, FormatJson::STRIP_COMMENTS ); - $this->assertInstanceOf( Status::class, $st ); - $this->assertTrue( $st->isGood() ); - $this->assertEquals( $expect, $st->getValue() ); - } - - /** - * Generate a set of test cases for a particular combination of encoder options. - * - * @param array $unescapedGroups List of character groups to leave unescaped - * @return array Arrays of unencoded strings and corresponding encoded strings - */ - private static function getEncodeTestCases( array $unescapedGroups ) { - $groups = [ - 'always' => [ - // Forward slash (always unescaped) - '/' => '/', - - // Control characters - "\0" => '\u0000', - "\x08" => '\b', - "\t" => '\t', - "\n" => '\n', - "\r" => '\r', - "\f" => '\f', - "\x1f" => '\u001f', // representative example - - // Double quotes - '"' => '\"', - - // Backslashes - '\\' => '\\\\', - '\\\\' => '\\\\\\\\', - '\\u00e9' => '\\\u00e9', // security check for Unicode unescaping - - // Line terminators - "\xe2\x80\xa8" => '\u2028', - "\xe2\x80\xa9" => '\u2029', - ], - 'unicode' => [ - "\xc3\xa9" => '\u00e9', - "\xf0\x9d\x92\x9e" => '\ud835\udc9e', // U+1D49E, outside the BMP - ], - 'xmlmeta' => [ - '<' => '\u003C', // JSON_HEX_TAG uses uppercase hex digits - '>' => '\u003E', - '&' => '\u0026', - ], - ]; - - $cases = []; - foreach ( $groups as $name => $rules ) { - $leaveUnescaped = in_array( $name, $unescapedGroups ); - foreach ( $rules as $from => $to ) { - $cases[] = [ $from, '"' . ( $leaveUnescaped ? $from : $to ) . '"' ]; - } - } - - return $cases; - } - - public function provideEmptyJsonKeyStrings() { - return [ - [ - '{"":"foo"}', - '{"":"foo"}', - '' - ], - [ - '{"_empty_":"foo"}', - '{"_empty_":"foo"}', - '_empty_' ], - [ - '{"\u005F\u0065\u006D\u0070\u0074\u0079\u005F":"foo"}', - '{"_empty_":"foo"}', - '_empty_' - ], - [ - '{"_empty_":"bar","":"foo"}', - '{"_empty_":"bar","":"foo"}', - '' - ], - [ - '{"":"bar","_empty_":"foo"}', - '{"":"bar","_empty_":"foo"}', - '_empty_' - ] - ]; - } - - /** - * @covers FormatJson::encode - * @covers FormatJson::decode - * @dataProvider provideEmptyJsonKeyStrings - * @param string $json - * - * Decoding behavior with empty keys can be surprising. - * See https://phabricator.wikimedia.org/T206411 - */ - public function testEmptyJsonKeyArray( $json, $expect, $php71Name ) { - // Decoding to array is consistent across supported PHP versions - $this->assertSame( $expect, FormatJson::encode( - FormatJson::decode( $json, true ) ) ); - - // Decoding to object differs between supported PHP versions - $obj = FormatJson::decode( $json ); - if ( version_compare( PHP_VERSION, '7.1', '<' ) ) { - $this->assertEquals( 'foo', $obj->_empty_ ); - } else { - $this->assertEquals( 'foo', $obj->{$php71Name} ); - } - } -} diff --git a/tests/phpunit/includes/libs/HashRingTest.php b/tests/phpunit/includes/libs/HashRingTest.php index acaeb02558..4afe3b56d5 100644 --- a/tests/phpunit/includes/libs/HashRingTest.php +++ b/tests/phpunit/includes/libs/HashRingTest.php @@ -316,8 +316,8 @@ EOT; // Hash of known correct values from C code $this->assertEquals( - 'c69ac9eb7a8a630c0cded201cefeaace', - md5( $ketama_test( 1e5 ) ), + 'd1a4912a80e4654ec2e4e462c8b911c6', + md5( $ketama_test( 1e3 ) ), 'Ketama mode (large, MD5 check)' ); diff --git a/tests/phpunit/includes/libs/IPTest.php b/tests/phpunit/includes/libs/IPTest.php index 9ec53c00c7..9f2fb1c5d7 100644 --- a/tests/phpunit/includes/libs/IPTest.php +++ b/tests/phpunit/includes/libs/IPTest.php @@ -481,7 +481,7 @@ class IPTest extends PHPUnit\Framework\TestCase { $this->assertFalseCIDR( '192.0.2.0/33', "mask > 32" ); // Check internal logic - # 0 mask always result in array(0,0) + # 0 mask always result in [ 0, 0 ] $this->assertEquals( [ 0, 0 ], IP::parseCIDR( '192.0.0.2/0' ) ); $this->assertEquals( [ 0, 0 ], IP::parseCIDR( '0.0.0.0/0' ) ); $this->assertEquals( [ 0, 0 ], IP::parseCIDR( '255.255.255.255/0' ) ); diff --git a/tests/phpunit/includes/libs/MapCacheLRUTest.php b/tests/phpunit/includes/libs/MapCacheLRUTest.php index 7147c6fa22..0c8dc68986 100644 --- a/tests/phpunit/includes/libs/MapCacheLRUTest.php +++ b/tests/phpunit/includes/libs/MapCacheLRUTest.php @@ -69,6 +69,21 @@ class MapCacheLRUTest extends PHPUnit\Framework\TestCase { ); } + /** + * @covers MapCacheLRU::has() + * @covers MapCacheLRU::get() + * @covers MapCacheLRU::set() + */ + function testMissing() { + $raw = [ 'a' => 1, 'b' => 2, 'c' => 3 ]; + $cache = MapCacheLRU::newFromArray( $raw, 3 ); + + $this->assertFalse( $cache->has( 'd' ) ); + $this->assertNull( $cache->get( 'd' ) ); + $this->assertNull( $cache->get( 'd', 0.0, null ) ); + $this->assertFalse( $cache->get( 'd', 0.0, false ) ); + } + /** * @covers MapCacheLRU::has() * @covers MapCacheLRU::get() diff --git a/tests/phpunit/includes/libs/ParamValidator/ParamValidatorTest.php b/tests/phpunit/includes/libs/ParamValidator/ParamValidatorTest.php new file mode 100644 index 0000000000..01b1c02362 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/ParamValidatorTest.php @@ -0,0 +1,506 @@ +getMockForAbstractClass( ContainerInterface::class ) ) + ); + $this->assertSame( array_keys( ParamValidator::$STANDARD_TYPES ), $validator->knownTypes() ); + + $validator = new ParamValidator( + new SimpleCallbacks( [] ), + new ObjectFactory( $this->getMockForAbstractClass( ContainerInterface::class ) ), + [ 'typeDefs' => [ 'foo' => [], 'bar' => [] ] ] + ); + $validator->addTypeDef( 'baz', [] ); + try { + $validator->addTypeDef( 'baz', [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + } + $validator->overrideTypeDef( 'bar', null ); + $validator->overrideTypeDef( 'baz', [] ); + $this->assertSame( [ 'foo', 'baz' ], $validator->knownTypes() ); + + $this->assertTrue( $validator->hasTypeDef( 'foo' ) ); + $this->assertFalse( $validator->hasTypeDef( 'bar' ) ); + $this->assertTrue( $validator->hasTypeDef( 'baz' ) ); + $this->assertFalse( $validator->hasTypeDef( 'bazz' ) ); + } + + public function testGetTypeDef() { + $callbacks = new SimpleCallbacks( [] ); + $factory = $this->getMockBuilder( ObjectFactory::class ) + ->setConstructorArgs( [ $this->getMockForAbstractClass( ContainerInterface::class ) ] ) + ->setMethods( [ 'createObject' ] ) + ->getMock(); + $factory->method( 'createObject' ) + ->willReturnCallback( function ( $spec, $options ) use ( $callbacks ) { + $this->assertInternalType( 'array', $spec ); + $this->assertSame( + [ 'extraArgs' => [ $callbacks ], 'assertClass' => TypeDef::class ], $options + ); + $ret = $this->getMockBuilder( TypeDef::class ) + ->setConstructorArgs( [ $callbacks ] ) + ->getMockForAbstractClass(); + $ret->spec = $spec; + return $ret; + } ); + $validator = new ParamValidator( $callbacks, $factory ); + + $def = $validator->getTypeDef( 'boolean' ); + $this->assertInstanceOf( TypeDef::class, $def ); + $this->assertSame( ParamValidator::$STANDARD_TYPES['boolean'], $def->spec ); + + $def = $validator->getTypeDef( [] ); + $this->assertInstanceOf( TypeDef::class, $def ); + $this->assertSame( ParamValidator::$STANDARD_TYPES['enum'], $def->spec ); + + $def = $validator->getTypeDef( 'missing' ); + $this->assertNull( $def ); + } + + public function testGetTypeDef_caching() { + $callbacks = new SimpleCallbacks( [] ); + + $mb = $this->getMockBuilder( TypeDef::class ) + ->setConstructorArgs( [ $callbacks ] ); + $def1 = $mb->getMockForAbstractClass(); + $def2 = $mb->getMockForAbstractClass(); + $this->assertNotSame( $def1, $def2, 'sanity check' ); + + $factory = $this->getMockBuilder( ObjectFactory::class ) + ->setConstructorArgs( [ $this->getMockForAbstractClass( ContainerInterface::class ) ] ) + ->setMethods( [ 'createObject' ] ) + ->getMock(); + $factory->expects( $this->once() )->method( 'createObject' )->willReturn( $def1 ); + + $validator = new ParamValidator( $callbacks, $factory, [ 'typeDefs' => [ + 'foo' => [], + 'bar' => $def2, + ] ] ); + + $this->assertSame( $def1, $validator->getTypeDef( 'foo' ) ); + + // Second call doesn't re-call ObjectFactory + $this->assertSame( $def1, $validator->getTypeDef( 'foo' ) ); + + // When registered a TypeDef directly, doesn't call ObjectFactory + $this->assertSame( $def2, $validator->getTypeDef( 'bar' ) ); + } + + /** + * @expectedException \UnexpectedValueException + * @expectedExceptionMessage Expected instance of Wikimedia\ParamValidator\TypeDef, got stdClass + */ + public function testGetTypeDef_error() { + $validator = new ParamValidator( + new SimpleCallbacks( [] ), + new ObjectFactory( $this->getMockForAbstractClass( ContainerInterface::class ) ), + [ 'typeDefs' => [ 'foo' => [ 'class' => \stdClass::class ] ] ] + ); + $validator->getTypeDef( 'foo' ); + } + + /** @dataProvider provideNormalizeSettings */ + public function testNormalizeSettings( $input, $expect ) { + $callbacks = new SimpleCallbacks( [] ); + + $mb = $this->getMockBuilder( TypeDef::class ) + ->setConstructorArgs( [ $callbacks ] ) + ->setMethods( [ 'normalizeSettings' ] ); + $mock1 = $mb->getMockForAbstractClass(); + $mock1->method( 'normalizeSettings' )->willReturnCallback( function ( $s ) { + $s['foo'] = 'FooBar!'; + return $s; + } ); + $mock2 = $mb->getMockForAbstractClass(); + $mock2->method( 'normalizeSettings' )->willReturnCallback( function ( $s ) { + $s['bar'] = 'FooBar!'; + return $s; + } ); + + $validator = new ParamValidator( + $callbacks, + new ObjectFactory( $this->getMockForAbstractClass( ContainerInterface::class ) ), + [ 'typeDefs' => [ 'foo' => $mock1, 'bar' => $mock2 ] ] + ); + + $this->assertSame( $expect, $validator->normalizeSettings( $input ) ); + } + + public static function provideNormalizeSettings() { + return [ + 'Plain value' => [ + 'ok?', + [ ParamValidator::PARAM_DEFAULT => 'ok?', ParamValidator::PARAM_TYPE => 'string' ], + ], + 'Simple array' => [ + [ 'test' => 'ok?' ], + [ 'test' => 'ok?', ParamValidator::PARAM_TYPE => 'NULL' ], + ], + 'A type with overrides' => [ + [ ParamValidator::PARAM_TYPE => 'foo', 'test' => 'ok?' ], + [ ParamValidator::PARAM_TYPE => 'foo', 'test' => 'ok?', 'foo' => 'FooBar!' ], + ], + ]; + } + + /** @dataProvider provideExplodeMultiValue */ + public function testExplodeMultiValue( $value, $limit, $expect ) { + $this->assertSame( $expect, ParamValidator::explodeMultiValue( $value, $limit ) ); + } + + public static function provideExplodeMultiValue() { + return [ + [ 'foobar', 100, [ 'foobar' ] ], + [ 'foo|bar|baz', 100, [ 'foo', 'bar', 'baz' ] ], + [ "\x1Ffoo\x1Fbar\x1Fbaz", 100, [ 'foo', 'bar', 'baz' ] ], + [ 'foo|bar|baz', 2, [ 'foo', 'bar|baz' ] ], + [ "\x1Ffoo\x1Fbar\x1Fbaz", 2, [ 'foo', "bar\x1Fbaz" ] ], + [ '|bar|baz', 100, [ '', 'bar', 'baz' ] ], + [ "\x1F\x1Fbar\x1Fbaz", 100, [ '', 'bar', 'baz' ] ], + [ '', 100, [] ], + [ "\x1F", 100, [] ], + ]; + } + + /** + * @expectedException DomainException + * @expectedExceptionMessage Param foo's type is unknown - string + */ + public function testGetValue_badType() { + $validator = new ParamValidator( + new SimpleCallbacks( [] ), + new ObjectFactory( $this->getMockForAbstractClass( ContainerInterface::class ) ), + [ 'typeDefs' => [] ] + ); + $validator->getValue( 'foo', 'default', [] ); + } + + /** @dataProvider provideGetValue */ + public function testGetValue( + $settings, $parseLimit, $get, $value, $isSensitive, $isDeprecated + ) { + $callbacks = new SimpleCallbacks( $get ); + $dummy = (object)[]; + $options = [ $dummy ]; + + $settings += [ + ParamValidator::PARAM_TYPE => 'xyz', + ParamValidator::PARAM_DEFAULT => null, + ]; + + $mockDef = $this->getMockBuilder( TypeDef::class ) + ->setConstructorArgs( [ $callbacks ] ) + ->getMockForAbstractClass(); + + // Mock the validateValue method so we can test only getValue + $validator = $this->getMockBuilder( ParamValidator::class ) + ->setConstructorArgs( [ + $callbacks, + new ObjectFactory( $this->getMockForAbstractClass( ContainerInterface::class ) ), + [ 'typeDefs' => [ 'xyz' => $mockDef ] ] + ] ) + ->setMethods( [ 'validateValue' ] ) + ->getMock(); + $validator->expects( $this->once() )->method( 'validateValue' ) + ->with( + $this->identicalTo( 'foobar' ), + $this->identicalTo( $value ), + $this->identicalTo( $settings ), + $this->identicalTo( $options ) + ) + ->willReturn( $dummy ); + + $this->assertSame( $dummy, $validator->getValue( 'foobar', $settings, $options ) ); + + $expectConditions = []; + if ( $isSensitive ) { + $expectConditions[] = new ValidationException( + 'foobar', $value, $settings, 'param-sensitive', [] + ); + } + if ( $isDeprecated ) { + $expectConditions[] = new ValidationException( + 'foobar', $value, $settings, 'param-deprecated', [] + ); + } + $this->assertEquals( $expectConditions, $callbacks->getRecordedConditions() ); + } + + public static function provideGetValue() { + $sen = [ ParamValidator::PARAM_SENSITIVE => true ]; + $dep = [ ParamValidator::PARAM_DEPRECATED => true ]; + $dflt = [ ParamValidator::PARAM_DEFAULT => 'DeFaUlT' ]; + return [ + 'Simple case' => [ [], false, [ 'foobar' => '!!!' ], '!!!', false, false ], + 'Not provided' => [ $sen + $dep, false, [], null, false, false ], + 'Not provided, default' => [ $sen + $dep + $dflt, true, [], 'DeFaUlT', false, false ], + 'Provided' => [ $dflt, false, [ 'foobar' => 'XYZ' ], 'XYZ', false, false ], + 'Provided, sensitive' => [ $sen, false, [ 'foobar' => 'XYZ' ], 'XYZ', true, false ], + 'Provided, deprecated' => [ $dep, false, [ 'foobar' => 'XYZ' ], 'XYZ', false, true ], + 'Provided array' => [ $dflt, false, [ 'foobar' => [ 'XYZ' ] ], [ 'XYZ' ], false, false ], + ]; + } + + /** + * @expectedException DomainException + * @expectedExceptionMessage Param foo's type is unknown - string + */ + public function testValidateValue_badType() { + $validator = new ParamValidator( + new SimpleCallbacks( [] ), + new ObjectFactory( $this->getMockForAbstractClass( ContainerInterface::class ) ), + [ 'typeDefs' => [] ] + ); + $validator->validateValue( 'foo', null, 'default', [] ); + } + + /** @dataProvider provideValidateValue */ + public function testValidateValue( + $value, $settings, $highLimits, $valuesList, $calls, $expect, $expectConditions = [], + $constructorOptions = [] + ) { + $callbacks = new SimpleCallbacks( [] ); + $settings += [ + ParamValidator::PARAM_TYPE => 'xyz', + ParamValidator::PARAM_DEFAULT => null, + ]; + $dummy = (object)[]; + $options = [ $dummy, 'useHighLimits' => $highLimits ]; + $eOptions = $options; + $eOptions2 = $eOptions; + if ( $valuesList !== null ) { + $eOptions2['values-list'] = $valuesList; + } + + $mockDef = $this->getMockBuilder( TypeDef::class ) + ->setConstructorArgs( [ $callbacks ] ) + ->setMethods( [ 'validate', 'getEnumValues' ] ) + ->getMockForAbstractClass(); + $mockDef->method( 'getEnumValues' ) + ->with( + $this->identicalTo( 'foobar' ), $this->identicalTo( $settings ), $this->identicalTo( $eOptions ) + ) + ->willReturn( [ 'a', 'b', 'c', 'd', 'e', 'f' ] ); + $mockDef->expects( $this->exactly( count( $calls ) ) )->method( 'validate' )->willReturnCallback( + function ( $n, $v, $s, $o ) use ( $settings, $eOptions2, $calls ) { + $this->assertSame( 'foobar', $n ); + $this->assertSame( $settings, $s ); + $this->assertSame( $eOptions2, $o ); + + if ( !array_key_exists( $v, $calls ) ) { + $this->fail( "Called with unexpected value '$v'" ); + } + if ( $calls[$v] === null ) { + throw new ValidationException( $n, $v, $s, 'badvalue', [] ); + } + return $calls[$v]; + } + ); + + $validator = new ParamValidator( + $callbacks, + new ObjectFactory( $this->getMockForAbstractClass( ContainerInterface::class ) ), + $constructorOptions + [ 'typeDefs' => [ 'xyz' => $mockDef ] ] + ); + + if ( $expect instanceof ValidationException ) { + try { + $validator->validateValue( 'foobar', $value, $settings, $options ); + $this->fail( 'Expected exception not thrown' ); + } catch ( ValidationException $ex ) { + $this->assertSame( $expect->getFailureCode(), $ex->getFailureCode() ); + $this->assertSame( $expect->getFailureData(), $ex->getFailureData() ); + } + } else { + $this->assertSame( + $expect, $validator->validateValue( 'foobar', $value, $settings, $options ) + ); + + $conditions = []; + foreach ( $callbacks->getRecordedConditions() as $c ) { + $conditions[] = array_merge( [ $c->getFailureCode() ], $c->getFailureData() ); + } + $this->assertSame( $expectConditions, $conditions ); + } + } + + public static function provideValidateValue() { + return [ + 'No value' => [ null, [], false, null, [], null ], + 'No value, required' => [ + null, + [ ParamValidator::PARAM_REQUIRED => true ], + false, + null, + [], + new ValidationException( 'foobar', null, [], 'missingparam', [] ), + ], + 'Non-multi value' => [ 'abc', [], false, null, [ 'abc' => 'def' ], 'def' ], + 'Simple multi value' => [ + 'a|b|c|d', + [ ParamValidator::PARAM_ISMULTI => true ], + false, + [ 'a', 'b', 'c', 'd' ], + [ 'a' => 'A', 'b' => 'B', 'c' => 'C', 'd' => 'D' ], + [ 'A', 'B', 'C', 'D' ], + ], + 'Array multi value' => [ + [ 'a', 'b', 'c', 'd' ], + [ ParamValidator::PARAM_ISMULTI => true ], + false, + [ 'a', 'b', 'c', 'd' ], + [ 'a' => 'A', 'b' => 'B', 'c' => 'C', 'd' => 'D' ], + [ 'A', 'B', 'C', 'D' ], + ], + 'Multi value with PARAM_ALL' => [ + '*', + [ ParamValidator::PARAM_ISMULTI => true, ParamValidator::PARAM_ALL => true ], + false, + null, + [], + [ 'a', 'b', 'c', 'd', 'e', 'f' ], + ], + 'Multi value with PARAM_ALL = "x"' => [ + 'x', + [ ParamValidator::PARAM_ISMULTI => true, ParamValidator::PARAM_ALL => "x" ], + false, + null, + [], + [ 'a', 'b', 'c', 'd', 'e', 'f' ], + ], + 'Multi value with PARAM_ALL = "x", passing "*"' => [ + '*', + [ ParamValidator::PARAM_ISMULTI => true, ParamValidator::PARAM_ALL => "x" ], + false, + [ '*' ], + [ '*' => '?' ], + [ '?' ], + ], + + 'Too many values' => [ + 'a|b|c|d', + [ + ParamValidator::PARAM_ISMULTI => true, + ParamValidator::PARAM_ISMULTI_LIMIT1 => 2, + ParamValidator::PARAM_ISMULTI_LIMIT2 => 4, + ], + false, + null, + [], + new ValidationException( 'foobar', 'a|b|c|d', [], 'toomanyvalues', [ 'limit' => 2 ] ), + ], + 'Too many values as array' => [ + [ 'a', 'b', 'c', 'd' ], + [ + ParamValidator::PARAM_ISMULTI => true, + ParamValidator::PARAM_ISMULTI_LIMIT1 => 2, + ParamValidator::PARAM_ISMULTI_LIMIT2 => 4, + ], + false, + null, + [], + new ValidationException( + 'foobar', [ 'a', 'b', 'c', 'd' ], [], 'toomanyvalues', [ 'limit' => 2 ] + ), + ], + 'Not too many values for highlimits' => [ + 'a|b|c|d', + [ + ParamValidator::PARAM_ISMULTI => true, + ParamValidator::PARAM_ISMULTI_LIMIT1 => 2, + ParamValidator::PARAM_ISMULTI_LIMIT2 => 4, + ], + true, + [ 'a', 'b', 'c', 'd' ], + [ 'a' => 'A', 'b' => 'B', 'c' => 'C', 'd' => 'D' ], + [ 'A', 'B', 'C', 'D' ], + ], + 'Too many values for highlimits' => [ + 'a|b|c|d|e', + [ + ParamValidator::PARAM_ISMULTI => true, + ParamValidator::PARAM_ISMULTI_LIMIT1 => 2, + ParamValidator::PARAM_ISMULTI_LIMIT2 => 4, + ], + true, + null, + [], + new ValidationException( 'foobar', 'a|b|c|d|e', [], 'toomanyvalues', [ 'limit' => 4 ] ), + ], + + 'Too many values via default' => [ + 'a|b|c|d', + [ + ParamValidator::PARAM_ISMULTI => true, + ], + false, + null, + [], + new ValidationException( 'foobar', 'a|b|c|d', [], 'toomanyvalues', [ 'limit' => 2 ] ), + [], + [ 'ismultiLimits' => [ 2, 4 ] ], + ], + 'Not too many values for highlimits via default' => [ + 'a|b|c|d', + [ + ParamValidator::PARAM_ISMULTI => true, + ], + true, + [ 'a', 'b', 'c', 'd' ], + [ 'a' => 'A', 'b' => 'B', 'c' => 'C', 'd' => 'D' ], + [ 'A', 'B', 'C', 'D' ], + [], + [ 'ismultiLimits' => [ 2, 4 ] ], + ], + 'Too many values for highlimits via default' => [ + 'a|b|c|d|e', + [ + ParamValidator::PARAM_ISMULTI => true, + ], + true, + null, + [], + new ValidationException( 'foobar', 'a|b|c|d|e', [], 'toomanyvalues', [ 'limit' => 4 ] ), + [], + [ 'ismultiLimits' => [ 2, 4 ] ], + ], + + 'Invalid values' => [ + 'a|b|c|d', + [ ParamValidator::PARAM_ISMULTI => true ], + false, + [ 'a', 'b', 'c', 'd' ], + [ 'a' => 'A', 'b' => null ], + new ValidationException( 'foobar', 'b', [], 'badvalue', [] ), + ], + 'Ignored invalid values' => [ + 'a|b|c|d', + [ + ParamValidator::PARAM_ISMULTI => true, + ParamValidator::PARAM_IGNORE_INVALID_VALUES => true, + ], + false, + [ 'a', 'b', 'c', 'd' ], + [ 'a' => 'A', 'b' => null, 'c' => null, 'd' => 'D' ], + [ 'A', 'D' ], + [ + [ 'unrecognizedvalues', 'values' => [ 'b', 'c' ] ], + ], + ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/SimpleCallbacksTest.php b/tests/phpunit/includes/libs/ParamValidator/SimpleCallbacksTest.php new file mode 100644 index 0000000000..ebe1dcc1d8 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/SimpleCallbacksTest.php @@ -0,0 +1,88 @@ + 'Foo!', 'bar' => null ], + [ + 'file1' => [ + 'name' => 'example.txt', + 'type' => 'text/plain', + 'tmp_name' => '...', + 'error' => UPLOAD_ERR_OK, + 'size' => 123, + ], + 'file2' => [ + 'name' => '', + 'type' => '', + 'tmp_name' => '', + 'error' => UPLOAD_ERR_NO_FILE, + 'size' => 0, + ], + ] + ); + + $this->assertTrue( $callbacks->hasParam( 'foo', [] ) ); + $this->assertFalse( $callbacks->hasParam( 'bar', [] ) ); + $this->assertFalse( $callbacks->hasParam( 'baz', [] ) ); + $this->assertFalse( $callbacks->hasParam( 'file1', [] ) ); + + $this->assertSame( 'Foo!', $callbacks->getValue( 'foo', null, [] ) ); + $this->assertSame( null, $callbacks->getValue( 'bar', null, [] ) ); + $this->assertSame( 123, $callbacks->getValue( 'bar', 123, [] ) ); + $this->assertSame( null, $callbacks->getValue( 'baz', null, [] ) ); + $this->assertSame( null, $callbacks->getValue( 'file1', null, [] ) ); + + $this->assertFalse( $callbacks->hasUpload( 'foo', [] ) ); + $this->assertFalse( $callbacks->hasUpload( 'bar', [] ) ); + $this->assertTrue( $callbacks->hasUpload( 'file1', [] ) ); + $this->assertTrue( $callbacks->hasUpload( 'file2', [] ) ); + $this->assertFalse( $callbacks->hasUpload( 'baz', [] ) ); + + $this->assertNull( $callbacks->getUploadedFile( 'foo', [] ) ); + $this->assertNull( $callbacks->getUploadedFile( 'bar', [] ) ); + $this->assertInstanceOf( + UploadedFileInterface::class, $callbacks->getUploadedFile( 'file1', [] ) + ); + $this->assertInstanceOf( + UploadedFileInterface::class, $callbacks->getUploadedFile( 'file2', [] ) + ); + $this->assertNull( $callbacks->getUploadedFile( 'baz', [] ) ); + + $file = $callbacks->getUploadedFile( 'file1', [] ); + $this->assertSame( 'example.txt', $file->getClientFilename() ); + $file = $callbacks->getUploadedFile( 'file2', [] ); + $this->assertSame( UPLOAD_ERR_NO_FILE, $file->getError() ); + + $this->assertFalse( $callbacks->useHighLimits( [] ) ); + $this->assertFalse( $callbacks->useHighLimits( [ 'useHighLimits' => false ] ) ); + $this->assertTrue( $callbacks->useHighLimits( [ 'useHighLimits' => true ] ) ); + } + + public function testRecording() { + $callbacks = new SimpleCallbacks( [] ); + + $this->assertSame( [], $callbacks->getRecordedConditions() ); + + $ex1 = new ValidationException( 'foo', 'Foo!', [], 'foo', [] ); + $callbacks->recordCondition( $ex1, [] ); + $ex2 = new ValidationException( 'bar', null, [], 'barbar', [ 'bAr' => 'BaR' ] ); + $callbacks->recordCondition( $ex2, [] ); + $callbacks->recordCondition( $ex2, [] ); + $this->assertSame( [ $ex1, $ex2, $ex2 ], $callbacks->getRecordedConditions() ); + + $callbacks->clearRecordedConditions(); + $this->assertSame( [], $callbacks->getRecordedConditions() ); + $callbacks->recordCondition( $ex1, [] ); + $this->assertSame( [ $ex1 ], $callbacks->getRecordedConditions() ); + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/BooleanDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/BooleanDefTest.php new file mode 100644 index 0000000000..75afb33214 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/BooleanDefTest.php @@ -0,0 +1,47 @@ + BooleanDef::$TRUEVALS, + 'falsevals' => array_merge( BooleanDef::$FALSEVALS, [ 'the empty string' ] ), + ] ); + + foreach ( [ + [ BooleanDef::$TRUEVALS, true ], + [ BooleanDef::$FALSEVALS, false ], + [ [ '' ], false ], + [ [ '2', 'foobar' ], $ex ], + ] as list( $vals, $expect ) ) { + foreach ( $vals as $v ) { + yield "Value '$v'" => [ $v, $expect ]; + $v2 = ucfirst( $v ); + if ( $v2 !== $v ) { + yield "Value '$v2'" => [ $v2, $expect ]; + } + $v3 = strtoupper( $v ); + if ( $v3 !== $v2 ) { + yield "Value '$v3'" => [ $v3, $expect ]; + } + } + } + } + + public function provideStringifyValue() { + return [ + [ true, 'true' ], + [ false, 'false' ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/EnumDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/EnumDefTest.php new file mode 100644 index 0000000000..18d0aca29c --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/EnumDefTest.php @@ -0,0 +1,64 @@ + [ 'a', 'b', 'c', 'd' ], + EnumDef::PARAM_DEPRECATED_VALUES => [ + 'b' => [ 'not-to-be' ], + 'c' => true, + ], + ]; + + return [ + 'Basic' => [ 'a', 'a', $settings ], + 'Deprecated' => [ 'c', 'c', $settings, [], [ [ 'deprecated-value', 'flag' => true ] ] ], + 'Deprecated with message' => [ + 'b', 'b', $settings, [], + [ [ 'deprecated-value', 'flag' => [ 'not-to-be' ] ] ], + ], + 'Bad value, non-multi' => [ + 'x', new ValidationException( 'test', 'x', $settings, 'badvalue', [] ), + $settings, + ], + 'Bad value, non-multi but looks like it' => [ + 'x|y', new ValidationException( 'test', 'x|y', $settings, 'notmulti', [] ), + $settings, + ], + 'Bad value, multi' => [ + 'x|y', new ValidationException( 'test', 'x|y', $settings, 'badvalue', [] ), + $settings + [ ParamValidator::PARAM_ISMULTI => true ], + [ 'values-list' => [ 'x|y' ] ], + ], + ]; + } + + public function provideGetEnumValues() { + return [ + 'Basic test' => [ + [ ParamValidator::PARAM_TYPE => [ 'a', 'b', 'c', 'd' ] ], + [ 'a', 'b', 'c', 'd' ], + ], + ]; + } + + public function provideStringifyValue() { + return [ + 'Basic test' => [ 123, '123' ], + 'Array' => [ [ 1, 2, 3 ], '1|2|3' ], + 'Array with pipes' => [ [ 1, 2, '3|4', 5 ], "\x1f1\x1f2\x1f3|4\x1f5" ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/FloatDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/FloatDefTest.php new file mode 100644 index 0000000000..7bd053aa79 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/FloatDefTest.php @@ -0,0 +1,122 @@ + [ '-0', 0 ], + 'Underflow is ok' => [ '1e-9999', 0 ], + + 'Empty decimal part' => [ '1.', new ValidationException( 'test', '1.', [], 'badfloat', [] ) ], + 'Bad sign' => [ ' 1', new ValidationException( 'test', ' 1', [], 'badfloat', [] ) ], + 'Comma as decimal separator or thousands grouping?' + => [ '1,234', new ValidationException( 'test', '1,234', [], 'badfloat', [] ) ], + 'U+2212 minus' => [ '−1', new ValidationException( 'test', '−1', [], 'badfloat', [] ) ], + 'Overflow' => [ '1e9999', new ValidationException( 'test', '1e9999', [], 'notfinite', [] ) ], + 'Overflow, -INF' + => [ '-1e9999', new ValidationException( 'test', '-1e9999', [], 'notfinite', [] ) ], + 'Bogus value' => [ 'foo', new ValidationException( 'test', 'foo', [], 'badfloat', [] ) ], + 'Bogus value (2)' => [ '123f4', new ValidationException( 'test', '123f4', [], 'badfloat', [] ) ], + 'Newline' => [ "123\n", new ValidationException( 'test', "123\n", [], 'badfloat', [] ) ], + ]; + } + + public function provideStringifyValue() { + $digits = defined( 'PHP_FLOAT_DIG' ) ? PHP_FLOAT_DIG : 15; + + return [ + [ 1.2, '1.2' ], + [ 10 / 3, '3.' . str_repeat( '3', $digits - 1 ) ], + [ 1e100, '1.0e+100' ], + [ 6.022e-23, '6.022e-23' ], + ]; + } + + /** @dataProvider provideLocales */ + public function testStringifyValue_localeWeirdness( $locale ) { + static $cats = [ LC_ALL, LC_MONETARY, LC_NUMERIC ]; + + $curLocales = []; + foreach ( $cats as $c ) { + $curLocales[$c] = setlocale( $c, '0' ); + if ( $curLocales[$c] === false ) { + $this->markTestSkipped( 'Locale support is unavailable' ); + } + } + try { + foreach ( $cats as $c ) { + if ( setlocale( $c, $locale ) === false ) { + $this->markTestSkipped( "Locale \"$locale\" is unavailable" ); + } + } + + $typeDef = $this->getInstance( new SimpleCallbacks( [] ), [] ); + $this->assertSame( '123456.789', $typeDef->stringifyValue( 'test', 123456.789, [], [] ) ); + $this->assertSame( '-123456.789', $typeDef->stringifyValue( 'test', -123456.789, [], [] ) ); + $this->assertSame( '1.0e+20', $typeDef->stringifyValue( 'test', 1e20, [], [] ) ); + $this->assertSame( '1.0e-20', $typeDef->stringifyValue( 'test', 1e-20, [], [] ) ); + } finally { + foreach ( $curLocales as $c => $v ) { + setlocale( $c, $v ); + } + } + } + + public function provideLocales() { + return [ + // May as well test these. + [ 'C' ], + [ 'C.UTF-8' ], + + // Some hopefullt-common locales with decimal_point = ',' and thousands_sep = '.' + [ 'de_DE' ], + [ 'de_DE.utf8' ], + [ 'es_ES' ], + [ 'es_ES.utf8' ], + + // This one, on my system at least, has decimal_point as U+066B. + [ 'ps_AF' ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/IntegerDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/IntegerDefTest.php new file mode 100644 index 0000000000..21fc9878c2 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/IntegerDefTest.php @@ -0,0 +1,159 @@ += 0; $i-- ) { + if ( $v[$i] === '9' ) { + $v[$i] = '0'; + } else { + $v[$i] = $v[$i] + 1; + return $v; + } + } + return '1' . $v; + } + + public function provideValidate() { + $badinteger = new ValidationException( 'test', '...', [], 'badinteger', [] ); + $belowminimum = new ValidationException( + 'test', '...', [], 'belowminimum', [ 'min' => 0, 'max' => 2, 'max2' => '' ] + ); + $abovemaximum = new ValidationException( + 'test', '...', [], 'abovemaximum', [ 'min' => 0, 'max' => 2, 'max2' => '' ] + ); + $abovemaximum2 = new ValidationException( + 'test', '...', [], 'abovemaximum', [ 'min' => 0, 'max' => 2, 'max2' => 4 ] + ); + $abovehighmaximum = new ValidationException( + 'test', '...', [], 'abovehighmaximum', [ 'min' => 0, 'max' => 2, 'max2' => 4 ] + ); + $asWarn = function ( ValidationException $ex ) { + return [ $ex->getFailureCode() ] + $ex->getFailureData(); + }; + + $minmax = [ + IntegerDef::PARAM_MIN => 0, + IntegerDef::PARAM_MAX => 2, + ]; + $minmax2 = [ + IntegerDef::PARAM_MIN => 0, + IntegerDef::PARAM_MAX => 2, + IntegerDef::PARAM_MAX2 => 4, + ]; + $ignore = [ + IntegerDef::PARAM_IGNORE_RANGE => true, + ]; + $usehigh = [ 'useHighLimits' => true ]; + + return [ + [ '123', 123 ], + [ '-123', -123 ], + [ '000123', 123 ], + [ '000', 0 ], + [ '-0', 0 ], + [ (string)PHP_INT_MAX, PHP_INT_MAX ], + [ '0000' . PHP_INT_MAX, PHP_INT_MAX ], + [ (string)PHP_INT_MIN, PHP_INT_MIN ], + [ '-0000' . substr( PHP_INT_MIN, 1 ), PHP_INT_MIN ], + + 'Overflow' => [ self::plusOne( (string)PHP_INT_MAX ), $badinteger ], + 'Negative overflow' => [ '-' . self::plusOne( substr( PHP_INT_MIN, 1 ) ), $badinteger ], + + 'Float' => [ '1.5', $badinteger ], + 'Float (e notation)' => [ '1e1', $badinteger ], + 'Bad sign (space)' => [ ' 1', $badinteger ], + 'Bad sign (newline)' => [ "\n1", $badinteger ], + 'Bogus value' => [ 'foo', $badinteger ], + 'Bogus value (2)' => [ '1foo', $badinteger ], + 'Hex value' => [ '0x123', $badinteger ], + 'Newline' => [ "1\n", $badinteger ], + + 'Ok with range' => [ '1', 1, $minmax ], + 'Below minimum' => [ '-1', $belowminimum, $minmax ], + 'Below minimum, ignored' => [ '-1', 0, $minmax + $ignore, [], [ $asWarn( $belowminimum ) ] ], + 'Above maximum' => [ '3', $abovemaximum, $minmax ], + 'Above maximum, ignored' => [ '3', 2, $minmax + $ignore, [], [ $asWarn( $abovemaximum ) ] ], + 'Not above max2 but can\'t use it' => [ '3', $abovemaximum2, $minmax2, [] ], + 'Not above max2 but can\'t use it, ignored' + => [ '3', 2, $minmax2 + $ignore, [], [ $asWarn( $abovemaximum2 ) ] ], + 'Not above max2' => [ '3', 3, $minmax2, $usehigh ], + 'Above max2' => [ '5', $abovehighmaximum, $minmax2, $usehigh ], + 'Above max2, ignored' + => [ '5', 4, $minmax2 + $ignore, $usehigh, [ $asWarn( $abovehighmaximum ) ] ], + ]; + } + + public function provideNormalizeSettings() { + return [ + [ [], [] ], + [ + [ IntegerDef::PARAM_MAX => 2 ], + [ IntegerDef::PARAM_MAX => 2 ], + ], + [ + [ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ], + [ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ], + ], + [ + [ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 4, IntegerDef::PARAM_MAX2 => 2 ], + [ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 4, IntegerDef::PARAM_MAX2 => 4 ], + ], + [ + [ IntegerDef::PARAM_MAX2 => 2 ], + [], + ], + ]; + } + + public function provideDescribeSettings() { + return [ + 'Basic' => [ [], [], [] ], + 'Default' => [ + [ ParamValidator::PARAM_DEFAULT => 123 ], + [ 'default' => '123' ], + [ 'default' => [ 'value' => '123' ] ], + ], + 'Min' => [ + [ ParamValidator::PARAM_DEFAULT => 123, IntegerDef::PARAM_MIN => 0 ], + [ 'default' => '123', 'min' => 0 ], + [ 'default' => [ 'value' => '123' ], 'min' => [ 'min' => 0, 'max' => '', 'max2' => '' ] ], + ], + 'Max' => [ + [ IntegerDef::PARAM_MAX => 2 ], + [ 'max' => 2 ], + [ 'max' => [ 'min' => '', 'max' => 2, 'max2' => '' ] ], + ], + 'Max2' => [ + [ IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ], + [ 'max' => 2, 'max2' => 4 ], + [ 'max2' => [ 'min' => '', 'max' => 2, 'max2' => 4 ] ], + ], + 'Minmax' => [ + [ IntegerDef::PARAM_MIN => 0, IntegerDef::PARAM_MAX => 2 ], + [ 'min' => 0, 'max' => 2 ], + [ 'minmax' => [ 'min' => 0, 'max' => 2, 'max2' => '' ] ], + ], + 'Minmax2' => [ + [ IntegerDef::PARAM_MIN => 0, IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ], + [ 'min' => 0, 'max' => 2, 'max2' => 4 ], + [ 'minmax2' => [ 'min' => 0, 'max' => 2, 'max2' => 4 ] ], + ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/LimitDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/LimitDefTest.php new file mode 100644 index 0000000000..2bf25e56e7 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/LimitDefTest.php @@ -0,0 +1,49 @@ + true ]; + $max = [ IntegerDef::PARAM_MAX => 2 ]; + $max2 = [ IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ]; + + yield 'Max' => [ 'max', 2, $max ]; + yield 'Max, use high' => [ 'max', 2, $max, $useHigh ]; + yield 'Max2' => [ 'max', 2, $max2 ]; + yield 'Max2, use high' => [ 'max', 4, $max2, $useHigh ]; + } + + public function provideNormalizeSettings() { + return [ + [ [], [ IntegerDef::PARAM_MIN => 0 ] ], + [ + [ IntegerDef::PARAM_MAX => 2 ], + [ IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MIN => 0 ], + ], + [ + [ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ], + [ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ], + ], + [ + [ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 4, IntegerDef::PARAM_MAX2 => 2 ], + [ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 4, IntegerDef::PARAM_MAX2 => 4 ], + ], + [ + [ IntegerDef::PARAM_MAX2 => 2 ], + [ IntegerDef::PARAM_MIN => 0 ], + ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/PasswordDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/PasswordDefTest.php new file mode 100644 index 0000000000..dd97903acd --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/PasswordDefTest.php @@ -0,0 +1,23 @@ + true ] ], + [ [ ParamValidator::PARAM_SENSITIVE => false ], [ ParamValidator::PARAM_SENSITIVE => true ] ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/PresenceBooleanDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/PresenceBooleanDefTest.php new file mode 100644 index 0000000000..dd690dee97 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/PresenceBooleanDefTest.php @@ -0,0 +1,31 @@ + 'foo' ], [], [] ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/StringDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/StringDefTest.php new file mode 100644 index 0000000000..bae2f026ec --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/StringDefTest.php @@ -0,0 +1,71 @@ + true, + ]; + $maxBytes = [ + StringDef::PARAM_MAX_BYTES => 4, + ]; + $maxChars = [ + StringDef::PARAM_MAX_CHARS => 2, + ]; + + return [ + 'Basic' => [ '123', '123' ], + 'Empty' => [ '', '' ], + 'Empty, required' => [ + '', + new ValidationException( 'test', '', [], 'missingparam', [] ), + $req, + ], + 'Empty, required, allowed' => [ '', '', $req, [ 'allowEmptyWhenRequired' => true ] ], + 'Max bytes, ok' => [ 'abcd', 'abcd', $maxBytes ], + 'Max bytes, exceeded' => [ + 'abcde', + new ValidationException( 'test', '', [], 'maxbytes', [ 'maxbytes' => 4, 'maxchars' => '' ] ), + $maxBytes, + ], + 'Max bytes, ok (2)' => [ '😄', '😄', $maxBytes ], + 'Max bytes, exceeded (2)' => [ + '😭?', + new ValidationException( 'test', '', [], 'maxbytes', [ 'maxbytes' => 4, 'maxchars' => '' ] ), + $maxBytes, + ], + 'Max chars, ok' => [ 'ab', 'ab', $maxChars ], + 'Max chars, exceeded' => [ + 'abc', + new ValidationException( 'test', '', [], 'maxchars', [ 'maxbytes' => '', 'maxchars' => 2 ] ), + $maxChars, + ], + 'Max chars, ok (2)' => [ '😄😄', '😄😄', $maxChars ], + 'Max chars, exceeded (2)' => [ + '😭??', + new ValidationException( 'test', '', [], 'maxchars', [ 'maxbytes' => '', 'maxchars' => 2 ] ), + $maxChars, + ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/TimestampDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/TimestampDefTest.php new file mode 100644 index 0000000000..8adf190d7a --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/TimestampDefTest.php @@ -0,0 +1,90 @@ + 'DateTime' ]; + $formatMW = [ TimestampDef::PARAM_TIMESTAMP_FORMAT => TS_MW ]; + + return [ + // We don't try to validate all formats supported by ConvertibleTimestamp, just + // some of the interesting ones. + 'ISO format' => [ '2018-02-03T04:05:06Z', $specific ], + 'ISO format with TZ' => [ '2018-02-03T00:05:06-04:00', $specific ], + 'ISO format without punctuation' => [ '20180203T040506', $specific ], + 'ISO format with ms' => [ '2018-02-03T04:05:06.999000Z', $specificMs ], + 'ISO format with ms without punctuation' => [ '20180203T040506.999', $specificMs ], + 'MW format' => [ '20180203040506', $specific ], + 'Generic format' => [ '2018-02-03 04:05:06', $specific ], + 'Generic format + GMT' => [ '2018-02-03 04:05:06 GMT', $specific ], + 'Generic format + TZ +0100' => [ '2018-02-03 05:05:06+0100', $specific ], + 'Generic format + TZ -01' => [ '2018-02-03 03:05:06-01', $specific ], + 'Seconds-since-epoch format' => [ '1517630706', $specific ], + 'Now' => [ 'now', $now ], + + // Warnings + 'Empty' => [ '', $now, [], [], [ [ 'unclearnowtimestamp' ] ] ], + 'Zero' => [ '0', $now, [], [], [ [ 'unclearnowtimestamp' ] ] ], + + // Error handling + 'Bad value' => [ + 'bogus', + new ValidationException( 'test', 'bogus', [], 'badtimestamp', [] ), + ], + + // Formatting + '=> DateTime' => [ 'now', $now->timestamp, $formatDT ], + '=> TS_MW' => [ 'now', '20190605195042', $formatMW ], + '=> TS_MW as default' => [ 'now', '20190605195042', [], [ 'defaultFormat' => TS_MW ] ], + '=> TS_MW overriding default' + => [ 'now', '20190605195042', $formatMW, [ 'defaultFormat' => TS_ISO_8601 ] ], + ]; + } + + public function provideStringifyValue() { + $specific = new ConvertibleTimestamp( '20180203040506' ); + + return [ + [ '20180203040506', '2018-02-03T04:05:06Z' ], + [ $specific, '2018-02-03T04:05:06Z' ], + [ $specific->timestamp, '2018-02-03T04:05:06Z' ], + [ $specific, '20180203040506', [], [ 'stringifyFormat' => TS_MW ] ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/TypeDefTestCase.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/TypeDefTestCase.php new file mode 100644 index 0000000000..fa86c79a05 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/TypeDefTestCase.php @@ -0,0 +1,193 @@ + $value ] ); + } + + /** + * Create an instance of the TypeDef subclass being tested + * + * @param SimpleCallbacks $callbacks From $this->getCallbacks() + * @param array $options Options array. + * @return TypeDef + */ + protected function getInstance( SimpleCallbacks $callbacks, array $options ) { + if ( static::$testClass === null ) { + throw new \LogicException( 'Either assign static::$testClass or override ' . __METHOD__ ); + } + + return new static::$testClass( $callbacks ); + } + + /** + * @dataProvider provideValidate + * @param mixed $value Value for getCallbacks() + * @param mixed|ValidationException $expect Expected result from TypeDef::validate(). + * If a ValidationException, it is expected that a ValidationException + * with matching failure code and data will be thrown. Otherwise, the return value must be equal. + * @param array $settings Settings array. + * @param array $options Options array + * @param array[] $expectConds Expected conditions reported. Each array is + * `[ $ex->getFailureCode() ] + $ex->getFailureData()`. + */ + public function testValidate( + $value, $expect, array $settings = [], array $options = [], array $expectConds = [] + ) { + $callbacks = $this->getCallbacks( $value, $options ); + $typeDef = $this->getInstance( $callbacks, $options ); + + if ( $expect instanceof ValidationException ) { + try { + $v = $typeDef->getValue( 'test', $settings, $options ); + $typeDef->validate( 'test', $v, $settings, $options ); + $this->fail( 'Expected exception not thrown' ); + } catch ( ValidationException $ex ) { + $this->assertEquals( $expect->getFailureCode(), $ex->getFailureCode() ); + $this->assertEquals( $expect->getFailureData(), $ex->getFailureData() ); + } + } else { + $v = $typeDef->getValue( 'test', $settings, $options ); + $this->assertEquals( $expect, $typeDef->validate( 'test', $v, $settings, $options ) ); + } + + $conditions = []; + foreach ( $callbacks->getRecordedConditions() as $ex ) { + $conditions[] = array_merge( [ $ex->getFailureCode() ], $ex->getFailureData() ); + } + $this->assertSame( $expectConds, $conditions ); + } + + /** + * @return array|Iterable + */ + abstract public function provideValidate(); + + /** + * @dataProvider provideNormalizeSettings + * @param array $settings + * @param array $expect + * @param array $options Options array + */ + public function testNormalizeSettings( array $settings, array $expect, array $options = [] ) { + $typeDef = $this->getInstance( new SimpleCallbacks( [] ), $options ); + $this->assertSame( $expect, $typeDef->normalizeSettings( $settings ) ); + } + + /** + * @return array|Iterable + */ + public function provideNormalizeSettings() { + return [ + 'Basic test' => [ [ 'param-foo' => 'bar' ], [ 'param-foo' => 'bar' ] ], + ]; + } + + /** + * @dataProvider provideGetEnumValues + * @param array $settings + * @param array|null $expect + * @param array $options Options array + */ + public function testGetEnumValues( array $settings, $expect, array $options = [] ) { + $typeDef = $this->getInstance( new SimpleCallbacks( [] ), $options ); + $this->assertSame( $expect, $typeDef->getEnumValues( 'test', $settings, $options ) ); + } + + /** + * @return array|Iterable + */ + public function provideGetEnumValues() { + return [ + 'Basic test' => [ [], null ], + ]; + } + + /** + * @dataProvider provideStringifyValue + * @param mixed $value + * @param string|null $expect + * @param array $settings + * @param array $options Options array + */ + public function testStringifyValue( $value, $expect, array $settings = [], array $options = [] ) { + $typeDef = $this->getInstance( new SimpleCallbacks( [] ), $options ); + $this->assertSame( $expect, $typeDef->stringifyValue( 'test', $value, $settings, $options ) ); + } + + /** + * @return array|Iterable + */ + public function provideStringifyValue() { + return [ + 'Basic test' => [ 123, '123' ], + ]; + } + + /** + * @dataProvider provideDescribeSettings + * @param array $settings + * @param array $expectNormal + * @param array $expectCompact + * @param array $options Options array + */ + public function testDescribeSettings( + array $settings, array $expectNormal, array $expectCompact, array $options = [] + ) { + $typeDef = $this->getInstance( new SimpleCallbacks( [] ), $options ); + $this->assertSame( + $expectNormal, + $typeDef->describeSettings( 'test', $settings, $options ), + 'Normal mode' + ); + $this->assertSame( + $expectCompact, + $typeDef->describeSettings( 'test', $settings, [ 'compact' => true ] + $options ), + 'Compact mode' + ); + } + + /** + * @return array|Iterable + */ + public function provideDescribeSettings() { + yield 'Basic test' => [ [], [], [] ]; + + foreach ( $this->provideStringifyValue() as $i => $v ) { + yield "Default value (from provideStringifyValue data set \"$i\")" => [ + [ ParamValidator::PARAM_DEFAULT => $v[0] ] + ( $v[2] ?? [] ), + [ 'default' => $v[1] ], + [ 'default' => [ 'value' => $v[1] ] ], + $v[3] ?? [], + ]; + } + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/UploadDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/UploadDefTest.php new file mode 100644 index 0000000000..c81647ca09 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/UploadDefTest.php @@ -0,0 +1,113 @@ + $value ] ); + } else { + return new SimpleCallbacks( [ 'test' => $value ] ); + } + } + + protected function getInstance( SimpleCallbacks $callbacks, array $options ) { + $ret = $this->getMockBuilder( UploadDef::class ) + ->setConstructorArgs( [ $callbacks ] ) + ->setMethods( [ 'getIniSize' ] ) + ->getMock(); + $ret->method( 'getIniSize' )->willReturn( $options['inisize'] ?? 2 * 1024 * 1024 ); + return $ret; + } + + private function makeUpload( $err = UPLOAD_ERR_OK ) { + return new UploadedFile( [ + 'name' => 'example.txt', + 'type' => 'text/plain', + 'size' => 0, + 'tmp_name' => '...', + 'error' => $err, + ] ); + } + + public function testGetNoFile() { + $typeDef = $this->getInstance( + $this->getCallbacks( $this->makeUpload( UPLOAD_ERR_NO_FILE ), [] ), + [] + ); + + $this->assertNull( $typeDef->getValue( 'test', [], [] ) ); + $this->assertNull( $typeDef->getValue( 'nothing', [], [] ) ); + } + + public function provideValidate() { + $okFile = $this->makeUpload(); + $iniFile = $this->makeUpload( UPLOAD_ERR_INI_SIZE ); + $exIni = new ValidationException( + 'test', '', [], 'badupload-inisize', [ 'size' => 2 * 1024 * 1024 * 1024 ] + ); + + return [ + 'Valid upload' => [ $okFile, $okFile ], + 'Not an upload' => [ + 'bar', + new ValidationException( 'test', 'bar', [], 'badupload-notupload', [] ), + ], + + 'Too big (bytes)' => [ $iniFile, $exIni, [], [ 'inisize' => 2 * 1024 * 1024 * 1024 ] ], + 'Too big (k)' => [ $iniFile, $exIni, [], [ 'inisize' => ( 2 * 1024 * 1024 ) . 'k' ] ], + 'Too big (K)' => [ $iniFile, $exIni, [], [ 'inisize' => ( 2 * 1024 * 1024 ) . 'K' ] ], + 'Too big (m)' => [ $iniFile, $exIni, [], [ 'inisize' => ( 2 * 1024 ) . 'm' ] ], + 'Too big (M)' => [ $iniFile, $exIni, [], [ 'inisize' => ( 2 * 1024 ) . 'M' ] ], + 'Too big (g)' => [ $iniFile, $exIni, [], [ 'inisize' => '2g' ] ], + 'Too big (G)' => [ $iniFile, $exIni, [], [ 'inisize' => '2G' ] ], + + 'Form size' => [ + $this->makeUpload( UPLOAD_ERR_FORM_SIZE ), + new ValidationException( 'test', '', [], 'badupload-formsize', [] ), + ], + 'Partial' => [ + $this->makeUpload( UPLOAD_ERR_PARTIAL ), + new ValidationException( 'test', '', [], 'badupload-partial', [] ), + ], + 'No tmp' => [ + $this->makeUpload( UPLOAD_ERR_NO_TMP_DIR ), + new ValidationException( 'test', '', [], 'badupload-notmpdir', [] ), + ], + 'Can\'t write' => [ + $this->makeUpload( UPLOAD_ERR_CANT_WRITE ), + new ValidationException( 'test', '', [], 'badupload-cantwrite', [] ), + ], + 'Ext abort' => [ + $this->makeUpload( UPLOAD_ERR_EXTENSION ), + new ValidationException( 'test', '', [], 'badupload-phpext', [] ), + ], + 'Unknown' => [ + $this->makeUpload( -43 ), // Should be safe from ever being an UPLOAD_ERR_* constant + new ValidationException( 'test', '', [], 'badupload-unknown', [ 'code' => -43 ] ), + ], + + 'Validating null' => [ + null, + new ValidationException( 'test', '', [], 'badupload', [] ), + ], + ]; + } + + public function provideStringifyValue() { + return [ + 'Yeah, right' => [ $this->makeUpload(), null ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDefTest.php new file mode 100644 index 0000000000..7675a8cc3f --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDefTest.php @@ -0,0 +1,79 @@ +getMockBuilder( TypeDef::class ) + ->setConstructorArgs( [ new SimpleCallbacks( [] ) ] ) + ->getMockForAbstractClass(); + + $this->assertSame( [ 'foobar' ], $typeDef->normalizeSettings( [ 'foobar' ] ) ); + $this->assertNull( $typeDef->getEnumValues( 'foobar', [], [] ) ); + $this->assertSame( '123', $typeDef->stringifyValue( 'foobar', 123, [], [] ) ); + } + + public function testGetValue() { + $options = [ (object)[] ]; + + $callbacks = $this->getMockBuilder( Callbacks::class )->getMockForAbstractClass(); + $callbacks->expects( $this->once() )->method( 'getValue' ) + ->with( + $this->identicalTo( 'foobar' ), + $this->identicalTo( null ), + $this->identicalTo( $options ) + ) + ->willReturn( 'zyx' ); + + $typeDef = $this->getMockBuilder( TypeDef::class ) + ->setConstructorArgs( [ $callbacks ] ) + ->getMockForAbstractClass(); + + $this->assertSame( + 'zyx', + $typeDef->getValue( 'foobar', [ ParamValidator::PARAM_DEFAULT => 'foo' ], $options ) + ); + } + + public function testDescribeSettings() { + $typeDef = $this->getMockBuilder( TypeDef::class ) + ->setConstructorArgs( [ new SimpleCallbacks( [] ) ] ) + ->getMockForAbstractClass(); + + $this->assertSame( + [], + $typeDef->describeSettings( + 'foobar', + [ ParamValidator::PARAM_TYPE => 'xxx' ], + [] + ) + ); + + $this->assertSame( + [ + 'default' => '123', + ], + $typeDef->describeSettings( + 'foobar', + [ ParamValidator::PARAM_DEFAULT => 123 ], + [] + ) + ); + + $this->assertSame( + [ + 'default' => [ 'value' => '123' ], + ], + $typeDef->describeSettings( + 'foobar', + [ ParamValidator::PARAM_DEFAULT => 123 ], + [ 'compact' => true ] + ) + ); + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileStreamTest.php b/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileStreamTest.php new file mode 100644 index 0000000000..9eaddf6882 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileStreamTest.php @@ -0,0 +1,294 @@ +makeTemp( __FUNCTION__ ); + unlink( $filename ); + + $this->assertFileNotExists( $filename, 'sanity check' ); + $stream = new UploadedFileStream( $filename ); + } + + /** + * @expectedException RuntimeException + * @expectedExceptionMessage Failed to open file: + */ + public function testConstruct_notReadable() { + $filename = $this->makeTemp( __FUNCTION__ ); + + chmod( $filename, 0000 ); + $stream = new UploadedFileStream( $filename ); + } + + public function testCloseOnDestruct() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + $fp = TestingAccessWrapper::newFromObject( $stream )->fp; + $this->assertSame( 'f', fread( $fp, 1 ), 'sanity check' ); + unset( $stream ); + $this->assertFalse( AtEase::quietCall( 'fread', $fp, 1 ) ); + } + + public function testToString() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + + // Always starts at the start of the stream + $stream->seek( 3 ); + $this->assertSame( 'foobar', (string)$stream ); + + // No exception when closed + $stream->close(); + $this->assertSame( '', (string)$stream ); + } + + public function testToString_Error() { + if ( !class_exists( \Error::class ) ) { + $this->markTestSkipped( 'No PHP Error class' ); + } + + // ... Yeah + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = $this->getMockBuilder( UploadedFileStream::class ) + ->setConstructorArgs( [ $filename ] ) + ->setMethods( [ 'getContents' ] ) + ->getMock(); + $stream->method( 'getContents' )->willReturnCallback( function () { + throw new \Error( 'Bogus' ); + } ); + $this->assertSame( '', (string)$stream ); + } + + public function testClose() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + + $stream->close(); + + // Second call doesn't error + $stream->close(); + } + + public function testDetach() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + + // We got the file descriptor + $fp = $stream->detach(); + $this->assertNotNull( $fp ); + $this->assertSame( 'f', fread( $fp, 1 ) ); + + // Stream operations now fail. + try { + $stream->seek( 0 ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + + // Stream close doesn't affect the file descriptor + $stream->close(); + $this->assertSame( 'o', fread( $fp, 1 ) ); + + // Stream destruction doesn't affect the file descriptor + unset( $stream ); + $this->assertSame( 'o', fread( $fp, 1 ) ); + + // On a closed stream, we don't get a file descriptor + $stream = new UploadedFileStream( $filename ); + $stream->close(); + $this->assertNull( $stream->detach() ); + } + + public function testGetSize() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + file_put_contents( $filename, 'foobarbaz' ); + $this->assertSame( 9, $stream->getSize() ); + + // Cached + file_put_contents( $filename, 'baz' ); + clearstatcache(); + $this->assertSame( 3, stat( $filename )['size'], 'sanity check' ); + $this->assertSame( 9, $stream->getSize() ); + + // No error if closed + $stream = new UploadedFileStream( $filename ); + $stream->close(); + $this->assertSame( null, $stream->getSize() ); + + // No error even if the fd goes bad + $stream = new UploadedFileStream( $filename ); + fclose( TestingAccessWrapper::newFromObject( $stream )->fp ); + $this->assertSame( null, $stream->getSize() ); + } + + public function testSeekTell() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + + $stream->seek( 2 ); + $this->assertSame( 2, $stream->tell() ); + $stream->seek( 2, SEEK_CUR ); + $this->assertSame( 4, $stream->tell() ); + $stream->seek( -5, SEEK_END ); + $this->assertSame( 1, $stream->tell() ); + $stream->read( 2 ); + $this->assertSame( 3, $stream->tell() ); + + $stream->close(); + try { + $stream->seek( 0 ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + try { + $stream->tell(); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + } + + public function testEof() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + + $this->assertFalse( $stream->eof() ); + $stream->getContents(); + $this->assertTrue( $stream->eof() ); + $stream->seek( -1, SEEK_END ); + $this->assertFalse( $stream->eof() ); + + // No error if closed + $stream = new UploadedFileStream( $filename ); + $stream->close(); + $this->assertTrue( $stream->eof() ); + + // No error even if the fd goes bad + $stream = new UploadedFileStream( $filename ); + fclose( TestingAccessWrapper::newFromObject( $stream )->fp ); + $this->assertInternalType( 'boolean', $stream->eof() ); + } + + public function testIsFuncs() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + $this->assertTrue( $stream->isSeekable() ); + $this->assertTrue( $stream->isReadable() ); + $this->assertFalse( $stream->isWritable() ); + + $stream->close(); + $this->assertFalse( $stream->isSeekable() ); + $this->assertFalse( $stream->isReadable() ); + $this->assertFalse( $stream->isWritable() ); + } + + public function testRewind() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + + $stream->seek( 2 ); + $this->assertSame( 2, $stream->tell() ); + $stream->rewind(); + $this->assertSame( 0, $stream->tell() ); + + $stream->close(); + try { + $stream->rewind(); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + } + + public function testWrite() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + + try { + $stream->write( 'foo' ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + } + + public function testRead() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + + $this->assertSame( 'foo', $stream->read( 3 ) ); + $this->assertSame( 'bar', $stream->read( 10 ) ); + $this->assertSame( '', $stream->read( 10 ) ); + $stream->rewind(); + $this->assertSame( 'foobar', $stream->read( 10 ) ); + + $stream->close(); + try { + $stream->read( 1 ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + } + + public function testGetContents() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + + $this->assertSame( 'foobar', $stream->getContents() ); + $this->assertSame( '', $stream->getContents() ); + $stream->seek( 3 ); + $this->assertSame( 'bar', $stream->getContents() ); + + $stream->close(); + try { + $stream->getContents(); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + } + + public function testGetMetadata() { + // Whatever + $filename = $this->makeTemp( __FUNCTION__ ); + $fp = fopen( $filename, 'r' ); + $expect = stream_get_meta_data( $fp ); + fclose( $fp ); + + $stream = new UploadedFileStream( $filename ); + $this->assertSame( $expect, $stream->getMetadata() ); + foreach ( $expect as $k => $v ) { + $this->assertSame( $v, $stream->getMetadata( $k ) ); + } + $this->assertNull( $stream->getMetadata( 'bogus' ) ); + + $stream->close(); + try { + $stream->getMetadata(); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileTest.php b/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileTest.php new file mode 100644 index 0000000000..80a74e7e74 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileTest.php @@ -0,0 +1,215 @@ +makeTemp( __FUNCTION__ ); + + $file = new UploadedFile( [ 'error' => UPLOAD_ERR_OK, 'tmp_name' => $filename ], false ); + + // getStream() fails for non-OK uploads + foreach ( [ + UPLOAD_ERR_INI_SIZE, + UPLOAD_ERR_FORM_SIZE, + UPLOAD_ERR_PARTIAL, + UPLOAD_ERR_NO_FILE, + UPLOAD_ERR_NO_TMP_DIR, + UPLOAD_ERR_CANT_WRITE, + UPLOAD_ERR_EXTENSION, + -42 + ] as $code ) { + $file2 = new UploadedFile( [ 'error' => $code, 'tmp_name' => $filename ], false ); + try { + $file2->getStream(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + } + + // getStream() works + $stream = $file->getStream(); + $this->assertInstanceOf( StreamInterface::class, $stream ); + $stream->seek( 0 ); + $this->assertSame( 'foobar', $stream->getContents() ); + + // Second call also works + $this->assertInstanceOf( StreamInterface::class, $file->getStream() ); + + // getStream() throws after move, and the stream is invalidated too + $file->moveTo( $filename . '.xxx' ); + try { + try { + $file->getStream(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + $this->assertSame( 'File has already been moved', $ex->getMessage() ); + } + try { + $stream->seek( 0 ); + $stream->getContents(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + } finally { + unlink( $filename . '.xxx' ); // Clean up + } + + // getStream() fails if the file is missing + $file = new UploadedFile( [ 'error' => UPLOAD_ERR_OK, 'tmp_name' => $filename ], true ); + try { + $file->getStream(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + $this->assertSame( 'Uploaded file is missing', $ex->getMessage() ); + } + } + + public function testMoveTo() { + // Successful move + $filename = $this->makeTemp( __FUNCTION__ ); + $this->assertFileExists( $filename, 'sanity check' ); + $this->assertFileNotExists( "$filename.xxx", 'sanity check' ); + $file = new UploadedFile( [ 'error' => UPLOAD_ERR_OK, 'tmp_name' => $filename ], false ); + $file->moveTo( $filename . '.xxx' ); + $this->assertFileNotExists( $filename ); + $this->assertFileExists( "$filename.xxx" ); + + // Fails on a second move attempt + $this->assertFileNotExists( "$filename.yyy", 'sanity check' ); + try { + $file->moveTo( $filename . '.yyy' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + $this->assertSame( 'File has already been moved', $ex->getMessage() ); + } + $this->assertFileNotExists( $filename ); + $this->assertFileExists( "$filename.xxx" ); + $this->assertFileNotExists( "$filename.yyy" ); + + // Fails if the file is missing + $file = new UploadedFile( [ 'error' => UPLOAD_ERR_OK, 'tmp_name' => "$filename.aaa" ], false ); + $this->assertFileNotExists( "$filename.aaa", 'sanity check' ); + $this->assertFileNotExists( "$filename.bbb", 'sanity check' ); + try { + $file->moveTo( $filename . '.bbb' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + $this->assertSame( 'Uploaded file is missing', $ex->getMessage() ); + } + $this->assertFileNotExists( "$filename.aaa" ); + $this->assertFileNotExists( "$filename.bbb" ); + + // Fails for non-upload file (when not flagged to ignore that) + $filename = $this->makeTemp( __FUNCTION__ ); + $this->assertFileExists( $filename, 'sanity check' ); + $this->assertFileNotExists( "$filename.xxx", 'sanity check' ); + $file = new UploadedFile( [ 'error' => UPLOAD_ERR_OK, 'tmp_name' => $filename ] ); + try { + $file->moveTo( $filename . '.xxx' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + $this->assertSame( 'Specified file is not an uploaded file', $ex->getMessage() ); + } + $this->assertFileExists( $filename ); + $this->assertFileNotExists( "$filename.xxx" ); + + // Fails for error uploads + $filename = $this->makeTemp( __FUNCTION__ ); + $this->assertFileExists( $filename, 'sanity check' ); + $this->assertFileNotExists( "$filename.xxx", 'sanity check' ); + foreach ( [ + UPLOAD_ERR_INI_SIZE, + UPLOAD_ERR_FORM_SIZE, + UPLOAD_ERR_PARTIAL, + UPLOAD_ERR_NO_FILE, + UPLOAD_ERR_NO_TMP_DIR, + UPLOAD_ERR_CANT_WRITE, + UPLOAD_ERR_EXTENSION, + -42 + ] as $code ) { + $file = new UploadedFile( [ 'error' => $code, 'tmp_name' => $filename ], false ); + try { + $file->moveTo( $filename . '.xxx' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + $this->assertFileExists( $filename ); + $this->assertFileNotExists( "$filename.xxx" ); + } + + // Move failure triggers exception + $filename = $this->makeTemp( __FUNCTION__, 'file1' ); + $filename2 = $this->makeTemp( __FUNCTION__, 'file2' ); + $this->assertFileExists( $filename, 'sanity check' ); + $file = new UploadedFile( [ 'error' => UPLOAD_ERR_OK, 'tmp_name' => $filename ], false ); + try { + $file->moveTo( $filename2 . DIRECTORY_SEPARATOR . 'foobar' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + $this->assertFileExists( $filename ); + } + + public function testInfoMethods() { + $filename = $this->makeTemp( __FUNCTION__ ); + $file = new UploadedFile( [ + 'name' => 'C:\\example.txt', + 'type' => 'text/plain', + 'size' => 1025, + 'error' => UPLOAD_ERR_OK, + 'tmp_name' => $filename, + ], false ); + $this->assertSame( 1025, $file->getSize() ); + $this->assertSame( UPLOAD_ERR_OK, $file->getError() ); + $this->assertSame( 'C:\\example.txt', $file->getClientFilename() ); + $this->assertSame( 'text/plain', $file->getClientMediaType() ); + + // None of these are allowed to error + $file = new UploadedFile( [], false ); + $this->assertSame( null, $file->getSize() ); + $this->assertSame( UPLOAD_ERR_NO_FILE, $file->getError() ); + $this->assertSame( null, $file->getClientFilename() ); + $this->assertSame( null, $file->getClientMediaType() ); + + // "if none was provided" behavior, given that $_FILES often contains + // the empty string. + $file = new UploadedFile( [ + 'name' => '', + 'type' => '', + 'size' => 100, + 'error' => UPLOAD_ERR_NO_FILE, + 'tmp_name' => $filename, + ], false ); + $this->assertSame( null, $file->getClientFilename() ); + $this->assertSame( null, $file->getClientMediaType() ); + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileTestBase.php b/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileTestBase.php new file mode 100644 index 0000000000..6e1bd6adbb --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileTestBase.php @@ -0,0 +1,82 @@ +isDir() ) { + rmdir( $file->getRealPath() ); + } else { + unlink( $file->getRealPath() ); + } + } + rmdir( self::$tmpdir ); + self::$tmpdir = null; + } + } + + protected static function assertTmpdir() { + if ( self::$tmpdir === null || !is_dir( self::$tmpdir ) ) { + self::fail( 'No temporary directory for ' . static::class ); + } + } + + /** + * @param string $prefix For tempnam() + * @param string $content Contents of the file + * @return string Filename + */ + protected function makeTemp( $prefix, $content = 'foobar' ) { + self::assertTmpdir(); + + $filename = tempnam( self::$tmpdir, $prefix ); + if ( $filename === false ) { + self::fail( 'Failed to create temporary file' ); + } + + self::assertSame( + strlen( $content ), + file_put_contents( $filename, $content ), + 'Writing test temporary file' + ); + + return $filename; + } + +} diff --git a/tests/phpunit/includes/libs/mime/MSCompoundFileReaderTest.php b/tests/phpunit/includes/libs/mime/MSCompoundFileReaderTest.php deleted file mode 100644 index 4509a61eb7..0000000000 --- a/tests/phpunit/includes/libs/mime/MSCompoundFileReaderTest.php +++ /dev/null @@ -1,60 +0,0 @@ -assertTrue( $info['valid'] ); - $this->assertSame( $expectedMime, $info['mime'] ); - } - - public static function provideInvalid() { - return [ - [ 'dir-beyond-end.xls', 'ERROR_READ_PAST_END' ], - [ 'fat-loop.xls', 'ERROR_INVALID_FORMAT' ], - [ 'invalid-signature.xls', 'ERROR_INVALID_SIGNATURE' ], - ]; - } - - /** @dataProvider provideInvalid */ - public function testReadFileInvalid( $fileName, $expectedError ) { - global $IP; - - $info = MSCompoundFileReader::readFile( "$IP/tests/phpunit/data/MSCompoundFileReader/$fileName" ); - $this->assertFalse( $info['valid'] ); - $this->assertSame( constant( MSCompoundFileReader::class . '::' . $expectedError ), - $info['errorCode'] ); - } -} diff --git a/tests/phpunit/includes/libs/mime/MimeAnalyzerTest.php b/tests/phpunit/includes/libs/mime/MimeAnalyzerTest.php index 194781207e..51ad915d44 100644 --- a/tests/phpunit/includes/libs/mime/MimeAnalyzerTest.php +++ b/tests/phpunit/includes/libs/mime/MimeAnalyzerTest.php @@ -137,4 +137,66 @@ class MimeAnalyzerTest extends PHPUnit\Framework\TestCase { $actualType = $this->doGuessMimeType( [ $file, 'doc' ] ); $this->assertEquals( 'application/msword', $actualType ); } + + /** + * @covers MimeAnalyzer::detectZipType + * @dataProvider provideOpendocumentsformatHeaders + */ + function testDetectZipTypeRecognizesOpendocuments( $expected, $header ) { + $this->assertEquals( + $expected, + $this->mimeAnalyzer->detectZipType( $header ) + ); + } + + /** + * An ODF file is a ZIP file of multiple files. The first one being + * 'mimetype' and is not compressed. + */ + function provideOpendocumentsformatHeaders() { + $thirtychars = str_repeat( 0, 30 ); + return [ + 'Database front end document header based on ODF 1.2' => [ + 'application/vnd.oasis.opendocument.base', + $thirtychars . 'mimetypeapplication/vnd.oasis.opendocument.basePK', + ], + ]; + } + + function providePngZipConfusion() { + return [ + [ + 'An invalid ZIP file due to the signature being too close to the ' . + 'end to accomodate an EOCDR', + 'zip-sig-near-end.png', + 'image/png', + ], + [ + 'An invalid ZIP file due to the comment length running beyond the ' . + 'end of the file', + 'zip-comment-overflow.png', + 'image/png', + ], + [ + 'A ZIP file similar to the above, but without either of those two ' . + 'problems. Not a valid ZIP file, but it passes MimeAnalyzer\'s ' . + 'definition of a ZIP file. This is mostly a sanity check of the ' . + 'above two tests.', + 'zip-kind-of-valid.png', + 'application/zip', + ], + [ + 'As above with non-zero comment length', + 'zip-kind-of-valid-2.png', + 'application/zip', + ], + ]; + } + + /** @dataProvider providePngZipConfusion */ + function testPngZipConfusion( $description, $fileName, $expectedType ) { + $file = __DIR__ . '/../../../data/media/' . $fileName; + $actualType = $this->doGuessMimeType( [ $file, 'png' ] ); + $this->assertEquals( $expectedType, $actualType, $description ); + } } diff --git a/tests/phpunit/includes/libs/objectcache/BagOStuffTest.php b/tests/phpunit/includes/libs/objectcache/BagOStuffTest.php index 4a09a2e00d..522af43662 100644 --- a/tests/phpunit/includes/libs/objectcache/BagOStuffTest.php +++ b/tests/phpunit/includes/libs/objectcache/BagOStuffTest.php @@ -1,10 +1,12 @@ * @group BagOStuff + * @covers BagOStuff */ class BagOStuffTest extends MediaWikiTestCase { /** @var BagOStuff */ @@ -30,8 +32,8 @@ class BagOStuffTest extends MediaWikiTestCase { } /** - * @covers BagOStuff::makeGlobalKey - * @covers BagOStuff::makeKeyInternal + * @covers MediumSpecificBagOStuff::makeGlobalKey + * @covers MediumSpecificBagOStuff::makeKeyInternal */ public function testMakeKey() { $cache = ObjectCache::newFromId( 'hash' ); @@ -64,8 +66,8 @@ class BagOStuffTest extends MediaWikiTestCase { } /** - * @covers BagOStuff::merge - * @covers BagOStuff::mergeViaCas + * @covers MediumSpecificBagOStuff::merge + * @covers MediumSpecificBagOStuff::mergeViaCas */ public function testMerge() { $key = $this->cache->makeKey( self::TEST_KEY ); @@ -98,39 +100,101 @@ class BagOStuffTest extends MediaWikiTestCase { $this->cache->merge( $key, $callback, 5, 1 ), 'Non-blocking merge (CAS)' ); + if ( $this->cache instanceof MultiWriteBagOStuff ) { - $wrapper = \Wikimedia\TestingAccessWrapper::newFromObject( $this->cache ); - $n = count( $wrapper->caches ); + $wrapper = TestingAccessWrapper::newFromObject( $this->cache ); + $this->assertEquals( count( $wrapper->caches ), $calls ); } else { - $n = 1; + $this->assertEquals( 1, $calls ); } - $this->assertEquals( $n, $calls ); } /** - * @covers BagOStuff::changeTTL + * @covers MediumSpecificBagOStuff::changeTTL */ public function testChangeTTL() { $key = $this->cache->makeKey( self::TEST_KEY ); $value = 'meow'; $this->cache->add( $key, $value, 5 ); - $this->assertTrue( $this->cache->changeTTL( $key, 5 ) ); + $this->assertEquals( $value, $this->cache->get( $key ) ); + $this->assertTrue( $this->cache->changeTTL( $key, 10 ) ); + $this->assertTrue( $this->cache->changeTTL( $key, 10 ) ); + $this->assertTrue( $this->cache->changeTTL( $key, 0 ) ); $this->assertEquals( $this->cache->get( $key ), $value ); $this->cache->delete( $key ); - $this->assertFalse( $this->cache->changeTTL( $key, 5 ) ); + $this->assertFalse( $this->cache->changeTTL( $key, 15 ) ); + + $this->cache->add( $key, $value, 5 ); + $this->assertTrue( $this->cache->changeTTL( $key, time() - 3600 ) ); + $this->assertFalse( $this->cache->get( $key ) ); } /** - * @covers BagOStuff::add + * @covers MediumSpecificBagOStuff::changeTTLMulti + */ + public function testChangeTTLMulti() { + $key1 = $this->cache->makeKey( 'test-key1' ); + $key2 = $this->cache->makeKey( 'test-key2' ); + $key3 = $this->cache->makeKey( 'test-key3' ); + $key4 = $this->cache->makeKey( 'test-key4' ); + + // cleanup + $this->cache->delete( $key1 ); + $this->cache->delete( $key2 ); + $this->cache->delete( $key3 ); + $this->cache->delete( $key4 ); + + $ok = $this->cache->changeTTLMulti( [ $key1, $key2, $key3 ], 30 ); + $this->assertFalse( $ok, "No keys found" ); + $this->assertFalse( $this->cache->get( $key1 ) ); + $this->assertFalse( $this->cache->get( $key2 ) ); + $this->assertFalse( $this->cache->get( $key3 ) ); + + $ok = $this->cache->setMulti( [ $key1 => 1, $key2 => 2, $key3 => 3 ] ); + + $this->assertTrue( $ok, "setMulti() succeeded" ); + $this->assertEquals( + 3, + count( $this->cache->getMulti( [ $key1, $key2, $key3 ] ) ), + "setMulti() succeeded via getMulti() check" + ); + + $ok = $this->cache->changeTTLMulti( [ $key1, $key2, $key3 ], 300 ); + $this->assertTrue( $ok, "TTL bumped for all keys" ); + $this->assertEquals( 1, $this->cache->get( $key1 ) ); + $this->assertEquals( 2, $this->cache->get( $key2 ) ); + $this->assertEquals( 3, $this->cache->get( $key3 ) ); + + $ok = $this->cache->changeTTLMulti( [ $key1, $key2, $key3 ], time() + 86400 ); + $this->assertTrue( $ok, "Expiry set for all keys" ); + + $ok = $this->cache->changeTTLMulti( [ $key1, $key2, $key3, $key4 ], 300 ); + $this->assertFalse( $ok, "One key missing" ); + + $this->assertEquals( 2, $this->cache->incr( $key1 ) ); + $this->assertEquals( 3, $this->cache->incr( $key2 ) ); + $this->assertEquals( 4, $this->cache->incr( $key3 ) ); + + // cleanup + $this->cache->delete( $key1 ); + $this->cache->delete( $key2 ); + $this->cache->delete( $key3 ); + $this->cache->delete( $key4 ); + } + + /** + * @covers MediumSpecificBagOStuff::add */ public function testAdd() { $key = $this->cache->makeKey( self::TEST_KEY ); + $this->assertFalse( $this->cache->get( $key ) ); $this->assertTrue( $this->cache->add( $key, 'test', 5 ) ); + $this->assertFalse( $this->cache->add( $key, 'test', 5 ) ); } /** - * @covers BagOStuff::get + * @covers MediumSpecificBagOStuff::get */ public function testGet() { $value = [ 'this' => 'is', 'a' => 'test' ]; @@ -141,9 +205,9 @@ class BagOStuffTest extends MediaWikiTestCase { } /** - * @covers BagOStuff::get - * @covers BagOStuff::set - * @covers BagOStuff::getWithSetCallback + * @covers MediumSpecificBagOStuff::get + * @covers MediumSpecificBagOStuff::set + * @covers MediumSpecificBagOStuff::getWithSetCallback */ public function testGetWithSetCallback() { $key = $this->cache->makeKey( self::TEST_KEY ); @@ -160,7 +224,7 @@ class BagOStuffTest extends MediaWikiTestCase { } /** - * @covers BagOStuff::incr + * @covers MediumSpecificBagOStuff::incr */ public function testIncr() { $key = $this->cache->makeKey( self::TEST_KEY ); @@ -172,7 +236,7 @@ class BagOStuffTest extends MediaWikiTestCase { } /** - * @covers BagOStuff::incrWithInit + * @covers MediumSpecificBagOStuff::incrWithInit */ public function testIncrWithInit() { $key = $this->cache->makeKey( self::TEST_KEY ); @@ -184,7 +248,7 @@ class BagOStuffTest extends MediaWikiTestCase { } /** - * @covers BagOStuff::getMulti + * @covers MediumSpecificBagOStuff::getMulti */ public function testGetMulti() { $value1 = [ 'this' => 'is', 'a' => 'test' ]; @@ -224,8 +288,8 @@ class BagOStuffTest extends MediaWikiTestCase { } /** - * @covers BagOStuff::setMulti - * @covers BagOStuff::deleteMulti + * @covers MediumSpecificBagOStuff::setMulti + * @covers MediumSpecificBagOStuff::deleteMulti */ public function testSetDeleteMulti() { $map = [ @@ -237,14 +301,18 @@ class BagOStuffTest extends MediaWikiTestCase { $this->cache->makeKey( 'test-6' ) => 'ever' ]; - $this->cache->setMulti( $map, 5 ); + $this->assertTrue( $this->cache->setMulti( $map ) ); $this->assertEquals( $map, $this->cache->getMulti( array_keys( $map ) ) ); - $this->assertTrue( $this->cache->deleteMulti( array_keys( $map ), 5 ) ); + $this->assertTrue( $this->cache->deleteMulti( array_keys( $map ) ) ); + $this->assertEquals( + [], + $this->cache->getMulti( array_keys( $map ), BagOStuff::READ_LATEST ) + ); $this->assertEquals( [], $this->cache->getMulti( array_keys( $map ) ) @@ -252,7 +320,57 @@ class BagOStuffTest extends MediaWikiTestCase { } /** - * @covers BagOStuff::getScopedLock + * @covers MediumSpecificBagOStuff::get + * @covers MediumSpecificBagOStuff::getMulti + * @covers MediumSpecificBagOStuff::merge + * @covers MediumSpecificBagOStuff::delete + */ + public function testSetSegmentable() { + $key = $this->cache->makeKey( self::TEST_KEY ); + $tiny = 418; + $small = wfRandomString( 32 ); + // 64 * 8 * 32768 = 16777216 bytes + $big = str_repeat( wfRandomString( 32 ) . '-' . wfRandomString( 32 ), 32768 ); + + $callback = function ( $cache, $key, $oldValue ) { + return $oldValue . '!'; + }; + + foreach ( [ $tiny, $small, $big ] as $value ) { + $this->cache->set( $key, $value, 10, BagOStuff::WRITE_ALLOW_SEGMENTS ); + $this->assertEquals( $value, $this->cache->get( $key ) ); + $this->assertEquals( $value, $this->cache->getMulti( [ $key ] )[$key] ); + + $this->assertTrue( $this->cache->merge( $key, $callback, 5 ) ); + $this->assertEquals( "$value!", $this->cache->get( $key ) ); + $this->assertEquals( "$value!", $this->cache->getMulti( [ $key ] )[$key] ); + + $this->assertTrue( $this->cache->deleteMulti( [ $key ] ) ); + $this->assertFalse( $this->cache->get( $key ) ); + $this->assertEquals( [], $this->cache->getMulti( [ $key ] ) ); + + $this->cache->set( $key, "@$value", 10, BagOStuff::WRITE_ALLOW_SEGMENTS ); + $this->assertEquals( "@$value", $this->cache->get( $key ) ); + $this->assertTrue( $this->cache->delete( $key, BagOStuff::WRITE_PRUNE_SEGMENTS ) ); + $this->assertFalse( $this->cache->get( $key ) ); + $this->assertEquals( [], $this->cache->getMulti( [ $key ] ) ); + } + + $this->cache->set( $key, 666, 10, BagOStuff::WRITE_ALLOW_SEGMENTS ); + + $this->assertEquals( 666, $this->cache->get( $key ) ); + $this->assertEquals( 667, $this->cache->incr( $key ) ); + $this->assertEquals( 667, $this->cache->get( $key ) ); + + $this->assertEquals( 664, $this->cache->decr( $key, 3 ) ); + $this->assertEquals( 664, $this->cache->get( $key ) ); + + $this->assertTrue( $this->cache->delete( $key ) ); + $this->assertFalse( $this->cache->get( $key ) ); + } + + /** + * @covers MediumSpecificBagOStuff::getScopedLock */ public function testGetScopedLock() { $key = $this->cache->makeKey( self::TEST_KEY ); @@ -276,8 +394,8 @@ class BagOStuffTest extends MediaWikiTestCase { } /** - * @covers BagOStuff::__construct - * @covers BagOStuff::trackDuplicateKeys + * @covers MediumSpecificBagOStuff::__construct + * @covers MediumSpecificBagOStuff::trackDuplicateKeys */ public function testReportDupes() { $logger = $this->createMock( Psr\Log\NullLogger::class ); @@ -301,8 +419,8 @@ class BagOStuffTest extends MediaWikiTestCase { } /** - * @covers BagOStuff::lock() - * @covers BagOStuff::unlock() + * @covers MediumSpecificBagOStuff::lock() + * @covers MediumSpecificBagOStuff::unlock() */ public function testLocking() { $key = 'test'; @@ -316,4 +434,11 @@ class BagOStuffTest extends MediaWikiTestCase { $this->assertTrue( $this->cache->unlock( $key2 ) ); $this->assertTrue( $this->cache->unlock( $key2 ) ); } + + public function tearDown() { + $this->cache->delete( $this->cache->makeKey( self::TEST_KEY ) ); + $this->cache->delete( $this->cache->makeKey( self::TEST_KEY ) . ':lock' ); + + parent::tearDown(); + } } diff --git a/tests/phpunit/includes/libs/objectcache/CachedBagOStuffTest.php b/tests/phpunit/includes/libs/objectcache/CachedBagOStuffTest.php index f953319e67..8c538734d8 100644 --- a/tests/phpunit/includes/libs/objectcache/CachedBagOStuffTest.php +++ b/tests/phpunit/includes/libs/objectcache/CachedBagOStuffTest.php @@ -125,6 +125,12 @@ class CachedBagOStuffTest extends PHPUnit\Framework\TestCase { * @covers CachedBagOStuff::makeKey */ public function testMakeKey() { + if ( defined( 'HHVM_VERSION' ) ) { + // This works fine on HHVM (and verified by integration tests), but due to + // a bug in HHVM's Reflection, PHPUnit 4 fails to create a mock (T228563) + $this->markTestSkipped( 'HHVM Reflection buggy' ); + } + $backend = $this->getMockBuilder( HashBagOStuff::class ) ->setMethods( [ 'makeKey' ] ) ->getMock(); @@ -145,6 +151,10 @@ class CachedBagOStuffTest extends PHPUnit\Framework\TestCase { * @covers CachedBagOStuff::makeGlobalKey */ public function testMakeGlobalKey() { + if ( defined( 'HHVM_VERSION' ) ) { + $this->markTestSkipped( 'HHVM Reflection buggy' ); + } + $backend = $this->getMockBuilder( HashBagOStuff::class ) ->setMethods( [ 'makeGlobalKey' ] ) ->getMock(); diff --git a/tests/phpunit/includes/libs/objectcache/MultiWriteBagOStuffTest.php b/tests/phpunit/includes/libs/objectcache/MultiWriteBagOStuffTest.php index 9f88474e7b..dc49a13dca 100644 --- a/tests/phpunit/includes/libs/objectcache/MultiWriteBagOStuffTest.php +++ b/tests/phpunit/includes/libs/objectcache/MultiWriteBagOStuffTest.php @@ -107,6 +107,10 @@ class MultiWriteBagOStuffTest extends MediaWikiTestCase { * @covers MultiWriteBagOStuff::makeKey */ public function testMakeKey() { + if ( defined( 'HHVM_VERSION' ) ) { + $this->markTestSkipped( 'HHVM Reflection buggy' ); + } + $cache1 = $this->getMockBuilder( HashBagOStuff::class ) ->setMethods( [ 'makeKey' ] )->getMock(); $cache1->expects( $this->once() )->method( 'makeKey' ) @@ -124,6 +128,10 @@ class MultiWriteBagOStuffTest extends MediaWikiTestCase { * @covers MultiWriteBagOStuff::makeGlobalKey */ public function testMakeGlobalKey() { + if ( defined( 'HHVM_VERSION' ) ) { + $this->markTestSkipped( 'HHVM Reflection buggy' ); + } + $cache1 = $this->getMockBuilder( HashBagOStuff::class ) ->setMethods( [ 'makeGlobalKey' ] )->getMock(); $cache1->expects( $this->once() )->method( 'makeGlobalKey' ) diff --git a/tests/phpunit/includes/libs/objectcache/ReplicatedBagOStuffTest.php b/tests/phpunit/includes/libs/objectcache/ReplicatedBagOStuffTest.php deleted file mode 100644 index 550ec0bd09..0000000000 --- a/tests/phpunit/includes/libs/objectcache/ReplicatedBagOStuffTest.php +++ /dev/null @@ -1,62 +0,0 @@ -writeCache = new HashBagOStuff(); - $this->readCache = new HashBagOStuff(); - $this->cache = new ReplicatedBagOStuff( [ - 'writeFactory' => $this->writeCache, - 'readFactory' => $this->readCache, - ] ); - } - - /** - * @covers ReplicatedBagOStuff::set - */ - public function testSet() { - $key = 'a key'; - $value = 'a value'; - $this->cache->set( $key, $value ); - - // Write to master. - $this->assertEquals( $value, $this->writeCache->get( $key ) ); - // Don't write to replica. Replication is deferred to backend. - $this->assertFalse( $this->readCache->get( $key ) ); - } - - /** - * @covers ReplicatedBagOStuff::get - */ - public function testGet() { - $key = 'a key'; - - $write = 'one value'; - $this->writeCache->set( $key, $write ); - $read = 'another value'; - $this->readCache->set( $key, $read ); - - // Read from replica. - $this->assertEquals( $read, $this->cache->get( $key ) ); - } - - /** - * @covers ReplicatedBagOStuff::get - */ - public function testGetAbsent() { - $key = 'a key'; - $value = 'a value'; - $this->writeCache->set( $key, $value ); - - // Don't read from master. No failover if value is absent. - $this->assertFalse( $this->cache->get( $key ) ); - } -} diff --git a/tests/phpunit/includes/libs/objectcache/WANObjectCacheTest.php b/tests/phpunit/includes/libs/objectcache/WANObjectCacheTest.php index 017d745e49..9e4a5d7d62 100644 --- a/tests/phpunit/includes/libs/objectcache/WANObjectCacheTest.php +++ b/tests/phpunit/includes/libs/objectcache/WANObjectCacheTest.php @@ -11,7 +11,7 @@ use Wikimedia\TestingAccessWrapper; * @covers WANObjectCache::getWarmupKeyMisses * @covers WANObjectCache::prefixCacheKeys * @covers WANObjectCache::getProcessCache - * @covers WANObjectCache::getNonProcessCachedKeys + * @covers WANObjectCache::getNonProcessCachedMultiKeys * @covers WANObjectCache::getRawKeysForWarmup * @covers WANObjectCache::getInterimValue * @covers WANObjectCache::setInterimValue @@ -47,18 +47,26 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { * @param int $ttl */ public function testSetAndGet( $value, $ttl ) { + $cache = $this->cache; + $curTTL = null; $asOf = null; - $key = $this->cache->makeKey( 'x', wfRandomString() ); + $key = $cache->makeKey( 'x', wfRandomString() ); - $this->cache->get( $key, $curTTL, [], $asOf ); + $cache->get( $key, $curTTL, [], $asOf ); $this->assertNull( $curTTL, "Current TTL is null" ); $this->assertNull( $asOf, "Current as-of-time is infinite" ); $t = microtime( true ); - $this->cache->set( $key, $value, $ttl ); - $this->assertEquals( $value, $this->cache->get( $key, $curTTL, [], $asOf ) ); + $cache->set( $key, $value, $cache::TTL_UNCACHEABLE ); + $cache->get( $key, $curTTL, [], $asOf ); + $this->assertNull( $curTTL, "Current TTL is null (TTL_UNCACHEABLE)" ); + $this->assertNull( $asOf, "Current as-of-time is infinite (TTL_UNCACHEABLE)" ); + + $cache->set( $key, $value, $ttl ); + + $this->assertEquals( $value, $cache->get( $key, $curTTL, [], $asOf ) ); if ( is_infinite( $ttl ) || $ttl == 0 ) { $this->assertTrue( is_infinite( $curTTL ), "Current TTL is infinite" ); } else { @@ -120,79 +128,153 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { $this->assertFalse( $this->cache->get( $key ), "Stale set() value ignored" ); } - public function testProcessCache() { + /** + * @covers WANObjectCache::getWithSetCallback + */ + public function testProcessCacheLruAndDelete() { + $cache = $this->cache; $mockWallClock = 1549343530.2053; - $this->cache->setMockTime( $mockWallClock ); + $cache->setMockTime( $mockWallClock ); $hit = 0; - $callback = function () use ( &$hit ) { + $fn = function () use ( &$hit ) { ++$hit; return 42; }; - $keys = [ wfRandomString(), wfRandomString(), wfRandomString() ]; - $groups = [ 'thiscache:1', 'thatcache:1', 'somecache:1' ]; + $keysA = [ wfRandomString(), wfRandomString(), wfRandomString() ]; + $keysB = [ wfRandomString(), wfRandomString(), wfRandomString() ]; + $pcg = [ 'thiscache:1', 'thatcache:1', 'somecache:1' ]; - foreach ( $keys as $i => $key ) { - $this->cache->getWithSetCallback( - $key, 100, $callback, [ 'pcTTL' => 5, 'pcGroup' => $groups[$i] ] ); + foreach ( $keysA as $i => $key ) { + $cache->getWithSetCallback( $key, 100, $fn, [ 'pcTTL' => 5, 'pcGroup' => $pcg[$i] ] ); } - $this->assertEquals( 3, $hit ); + $this->assertEquals( 3, $hit, "Values not cached yet" ); - foreach ( $keys as $i => $key ) { - $this->cache->getWithSetCallback( - $key, 100, $callback, [ 'pcTTL' => 5, 'pcGroup' => $groups[$i] ] ); + foreach ( $keysA as $i => $key ) { + // Should not evict from process cache + $cache->delete( $key ); + $cache->getWithSetCallback( $key, 100, $fn, [ 'pcTTL' => 5, 'pcGroup' => $pcg[$i] ] ); } - $this->assertEquals( 3, $hit, "Values cached" ); + $this->assertEquals( 3, $hit, "Values cached; not cleared by delete()" ); - foreach ( $keys as $i => $key ) { - $this->cache->getWithSetCallback( - "$key-2", 100, $callback, [ 'pcTTL' => 5, 'pcGroup' => $groups[$i] ] ); + foreach ( $keysB as $i => $key ) { + $cache->getWithSetCallback( $key, 100, $fn, [ 'pcTTL' => 5, 'pcGroup' => $pcg[$i] ] ); } - $this->assertEquals( 6, $hit ); + $this->assertEquals( 6, $hit, "New values not cached yet" ); - foreach ( $keys as $i => $key ) { - $this->cache->getWithSetCallback( - "$key-2", 100, $callback, [ 'pcTTL' => 5, 'pcGroup' => $groups[$i] ] ); + foreach ( $keysB as $i => $key ) { + $cache->getWithSetCallback( $key, 100, $fn, [ 'pcTTL' => 5, 'pcGroup' => $pcg[$i] ] ); } $this->assertEquals( 6, $hit, "New values cached" ); - foreach ( $keys as $i => $key ) { - // Should evict from process cache - $this->cache->delete( $key ); + foreach ( $keysA as $i => $key ) { + $cache->getWithSetCallback( $key, 100, $fn, [ 'pcTTL' => 5, 'pcGroup' => $pcg[$i] ] ); + } + $this->assertEquals( 9, $hit, "Prior values evicted by new values" ); + } + + /** + * @covers WANObjectCache::getWithSetCallback + */ + public function testProcessCacheInterimKeys() { + $cache = $this->cache; + $mockWallClock = 1549343530.2053; + $cache->setMockTime( $mockWallClock ); + + $hit = 0; + $fn = function () use ( &$hit ) { + ++$hit; + return 42; + }; + $keysA = [ wfRandomString(), wfRandomString(), wfRandomString() ]; + $pcg = [ 'thiscache:1', 'thatcache:1', 'somecache:1' ]; + + foreach ( $keysA as $i => $key ) { + $cache->delete( $key ); // tombstone key $mockWallClock += 0.001; // cached values will be newer than tombstone - // Get into cache (specific process cache group) - $this->cache->getWithSetCallback( - $key, 100, $callback, [ 'pcTTL' => 5, 'pcGroup' => $groups[$i] ] ); + // Get into process cache (specific group) and interim cache + $cache->getWithSetCallback( $key, 100, $fn, [ 'pcTTL' => 5, 'pcGroup' => $pcg[$i] ] ); } - $this->assertEquals( 9, $hit, "Values evicted by delete()" ); + $this->assertEquals( 3, $hit ); - // Get into cache (default process cache group) - $key = reset( $keys ); - $this->cache->getWithSetCallback( $key, 100, $callback, [ 'pcTTL' => 5 ] ); - $this->assertEquals( 9, $hit, "Value recently interim-cached" ); + // Get into process cache (default group) + $key = reset( $keysA ); + $cache->getWithSetCallback( $key, 100, $fn, [ 'pcTTL' => 5 ] ); + $this->assertEquals( 3, $hit, "Value recently interim-cached" ); $mockWallClock += 0.2; // interim key not brand new - $this->cache->clearProcessCache(); - $this->cache->getWithSetCallback( $key, 100, $callback, [ 'pcTTL' => 5 ] ); - $this->assertEquals( 10, $hit, "Value calculated (interim key not recent and reset)" ); - $this->cache->getWithSetCallback( $key, 100, $callback, [ 'pcTTL' => 5 ] ); - $this->assertEquals( 10, $hit, "Value process cached" ); + $cache->clearProcessCache(); + $cache->getWithSetCallback( $key, 100, $fn, [ 'pcTTL' => 5 ] ); + $this->assertEquals( 4, $hit, "Value calculated (interim key not recent and reset)" ); + $cache->getWithSetCallback( $key, 100, $fn, [ 'pcTTL' => 5 ] ); + $this->assertEquals( 4, $hit, "Value process cached" ); + } - $mockWallClock += 0.2; // interim key not brand new - $outerCallback = function () use ( &$callback, $key ) { - $v = $this->cache->getWithSetCallback( $key, 100, $callback, [ 'pcTTL' => 5 ] ); + /** + * @covers WANObjectCache::getWithSetCallback + */ + public function testProcessCacheNesting() { + $cache = $this->cache; + $mockWallClock = 1549343530.2053; + $cache->setMockTime( $mockWallClock ); + + $keyOuter = "outer-" . wfRandomString(); + $keyInner = "inner-" . wfRandomString(); + + $innerHit = 0; + $innerFn = function () use ( &$innerHit ) { + ++$innerHit; + return 42; + }; + + $outerHit = 0; + $outerFn = function () use ( $keyInner, $innerFn, $cache, &$outerHit ) { + ++$outerHit; + $v = $cache->getWithSetCallback( $keyInner, 100, $innerFn, [ 'pcTTL' => 5 ] ); return 43 + $v; }; - // Outer key misses and refuses inner key process cache value - $this->cache->getWithSetCallback( "$key-miss-outer", 100, $outerCallback ); - $this->assertEquals( 11, $hit, "Nested callback value process cache skipped" ); + + $cache->getWithSetCallback( $keyInner, 100, $innerFn, [ 'pcTTL' => 5 ] ); + $cache->getWithSetCallback( $keyInner, 100, $innerFn, [ 'pcTTL' => 5 ] ); + + $this->assertEquals( 1, $innerHit, "Inner callback value cached" ); + $cache->delete( $keyInner, $cache::HOLDOFF_NONE ); + $mockWallClock += 1; + + $cache->getWithSetCallback( $keyInner, 100, $innerFn, [ 'pcTTL' => 5 ] ); + $this->assertEquals( 1, $innerHit, "Inner callback process cached" ); + + // Outer key misses and inner key process cache value is refused + $cache->getWithSetCallback( $keyOuter, 100, $outerFn ); + + $this->assertEquals( 1, $outerHit, "Outer callback value not yet cached" ); + $this->assertEquals( 2, $innerHit, "Inner callback value process cache skipped" ); + + $cache->getWithSetCallback( $keyOuter, 100, $outerFn ); + + $this->assertEquals( 1, $outerHit, "Outer callback value cached" ); + + $cache->delete( $keyInner, $cache::HOLDOFF_NONE ); + $cache->delete( $keyOuter, $cache::HOLDOFF_NONE ); + $mockWallClock += 1; + $cache->clearProcessCache(); + $cache->getWithSetCallback( $keyOuter, 100, $outerFn ); + + $this->assertEquals( 2, $outerHit, "Outer callback value not yet cached" ); + $this->assertEquals( 3, $innerHit, "Inner callback value not yet cached" ); + + $cache->delete( $keyInner, $cache::HOLDOFF_NONE ); + $mockWallClock += 1; + $cache->getWithSetCallback( $keyInner, 100, $innerFn, [ 'pcTTL' => 5 ] ); + + $this->assertEquals( 3, $innerHit, "Inner callback value process cached" ); } /** * @dataProvider getWithSetCallback_provider * @covers WANObjectCache::getWithSetCallback() - * @covers WANObjectCache::doGetWithSetCallback() + * @covers WANObjectCache::fetchOrRegenerate() * @param array $extOpts * @param bool $versioned */ @@ -268,11 +350,7 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { $curTTL = null; $v = $cache->get( $key, $curTTL, [ $cKey1, $cKey2 ] ); - if ( $versioned ) { - $this->assertEquals( $value, $v[$cache::VFLD_DATA], "Value returned" ); - } else { - $this->assertEquals( $value, $v, "Value returned" ); - } + $this->assertEquals( $value, $v, "Value returned" ); $this->assertLessThanOrEqual( 0, $curTTL, "Value has current TTL < 0 due to check keys" ); $wasSet = 0; @@ -378,7 +456,7 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { /** * @dataProvider getWithSetCallback_provider * @covers WANObjectCache::getWithSetCallback() - * @covers WANObjectCache::doGetWithSetCallback() + * @covers WANObjectCache::fetchOrRegenerate() * @param array $extOpts * @param bool $versioned */ @@ -544,15 +622,6 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { $this->assertEquals( 2, $wasSet, "Value re-calculated" ); } - /** - * @covers WANObjectCache::getWithSetCallback() - * @covers WANObjectCache::doGetWithSetCallback() - */ - public function testGetWithSetCallback_invalidCallback() { - $this->setExpectedException( InvalidArgumentException::class ); - $this->cache->getWithSetCallback( 'key', 30, 'invalid callback' ); - } - /** * @dataProvider getMultiWithSetCallback_provider * @covers WANObjectCache::getMultiWithSetCallback @@ -606,15 +675,16 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { $value = "@efef$"; $keyedIds = new ArrayIterator( [ $keyB => 'efef' ] ); $v = $cache->getMultiWithSetCallback( - $keyedIds, 30, $genFunc, [ 'lowTTL' => 0, 'lockTSE' => 5, ] + $extOpts ); + $keyedIds, 30, $genFunc, [ 'lowTTL' => 0, 'lockTSE' => 5 ] + $extOpts ); $this->assertEquals( $value, $v[$keyB], "Value returned" ); $this->assertEquals( 1, $wasSet, "Value regenerated" ); - $this->assertEquals( 0, $cache->getWarmupKeyMisses(), "Keys warmed yet in process cache" ); + $this->assertEquals( 0, $cache->getWarmupKeyMisses(), "Keys warmed in warmup cache" ); + $v = $cache->getMultiWithSetCallback( - $keyedIds, 30, $genFunc, [ 'lowTTL' => 0, 'lockTSE' => 5, ] + $extOpts ); + $keyedIds, 30, $genFunc, [ 'lowTTL' => 0, 'lockTSE' => 5 ] + $extOpts ); $this->assertEquals( $value, $v[$keyB], "Value returned" ); $this->assertEquals( 1, $wasSet, "Value not regenerated" ); - $this->assertEquals( 0, $cache->getWarmupKeyMisses(), "Keys warmed in process cache" ); + $this->assertEquals( 0, $cache->getWarmupKeyMisses(), "Keys warmed in warmup cache" ); $mockWallClock += 1; @@ -649,11 +719,7 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { $curTTL = null; $v = $cache->get( $keyC, $curTTL, [ $cKey1, $cKey2 ] ); - if ( $versioned ) { - $this->assertEquals( $value, $v[$cache::VFLD_DATA], "Value returned" ); - } else { - $this->assertEquals( $value, $v, "Value returned" ); - } + $this->assertEquals( $value, $v, "Value returned" ); $this->assertLessThanOrEqual( 0, $curTTL, "Value has current TTL < 0 due to check keys" ); $wasSet = 0; @@ -701,8 +767,8 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { $localBag = $this->getMockBuilder( HashBagOStuff::class ) ->setMethods( [ 'getMulti' ] )->getMock(); $localBag->expects( $this->exactly( 1 ) )->method( 'getMulti' )->willReturn( [ - WANObjectCache::VALUE_KEY_PREFIX . 'k1' => 'val-id1', - WANObjectCache::VALUE_KEY_PREFIX . 'k2' => 'val-id2' + 'WANCache:v:' . 'k1' => 'val-id1', + 'WANCache:v:' . 'k2' => 'val-id2' ] ); $wanCache = new WANObjectCache( [ 'cache' => $localBag ] ); @@ -780,12 +846,13 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { $keyedIds, 30, $genFunc, [ 'lowTTL' => 0 ] + $extOpts ); $this->assertEquals( $value, $v[$keyB], "Value returned" ); $this->assertEquals( 1, $wasSet, "Value regenerated" ); - $this->assertEquals( 0, $cache->getWarmupKeyMisses(), "Keys warmed yet in process cache" ); + $this->assertEquals( 0, $cache->getWarmupKeyMisses(), "Keys warmed in warmup cache" ); + $v = $cache->getMultiWithUnionSetCallback( $keyedIds, 30, $genFunc, [ 'lowTTL' => 0 ] + $extOpts ); $this->assertEquals( $value, $v[$keyB], "Value returned" ); $this->assertEquals( 1, $wasSet, "Value not regenerated" ); - $this->assertEquals( 0, $cache->getWarmupKeyMisses(), "Keys warmed in process cache" ); + $this->assertEquals( 0, $cache->getWarmupKeyMisses(), "Keys warmed in warmup cache" ); $mockWallClock += 1; @@ -818,11 +885,7 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { $curTTL = null; $v = $cache->get( $keyC, $curTTL, [ $cKey1, $cKey2 ] ); - if ( $versioned ) { - $this->assertEquals( $value, $v[$cache::VFLD_DATA], "Value returned" ); - } else { - $this->assertEquals( $value, $v, "Value returned" ); - } + $this->assertEquals( $value, $v, "Value returned" ); $this->assertLessThanOrEqual( 0, $curTTL, "Value has current TTL < 0 due to check keys" ); $wasSet = 0; @@ -880,7 +943,7 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { /** * @covers WANObjectCache::getWithSetCallback() - * @covers WANObjectCache::doGetWithSetCallback() + * @covers WANObjectCache::fetchOrRegenerate() */ public function testLockTSE() { $cache = $this->cache; @@ -901,7 +964,7 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { $this->assertEquals( 1, $calls, 'Value was populated' ); // Acquire the mutex to verify that getWithSetCallback uses lockTSE properly - $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 ); + $this->internalCache->add( 'WANCache:m:' . $key, 1, 0 ); $checkKeys = [ wfRandomString() ]; // new check keys => force misses $ret = $cache->getWithSetCallback( $key, 30, $func, @@ -924,7 +987,7 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { /** * @covers WANObjectCache::getWithSetCallback() - * @covers WANObjectCache::doGetWithSetCallback() + * @covers WANObjectCache::fetchOrRegenerate() * @covers WANObjectCache::set() */ public function testLockTSESlow() { @@ -963,7 +1026,7 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { $mockWallClock += 2; // low logical TTL expired // Acquire a lock to verify that getWithSetCallback uses lockTSE properly - $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 ); + $this->internalCache->add( 'WANCache:m:' . $key, 1, 0 ); $ret = $cache->getWithSetCallback( $key, 300, $func, [ 'lockTSE' => 5 ] ); $this->assertEquals( $value, $ret ); @@ -971,7 +1034,7 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { $mockWallClock += 301; // physical TTL expired // Acquire a lock to verify that getWithSetCallback uses lockTSE properly - $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 ); + $this->internalCache->add( 'WANCache:m:' . $key, 1, 0 ); $ret = $cache->getWithSetCallback( $key, 300, $func, [ 'lockTSE' => 5 ] ); $this->assertEquals( $value, $ret ); @@ -1005,7 +1068,7 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { /** * @covers WANObjectCache::getWithSetCallback() - * @covers WANObjectCache::doGetWithSetCallback() + * @covers WANObjectCache::fetchOrRegenerate() */ public function testBusyValue() { $cache = $this->cache; @@ -1029,7 +1092,7 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { $mockWallClock += 0.2; // interim keys not brand new // Acquire a lock to verify that getWithSetCallback uses busyValue properly - $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 ); + $this->internalCache->add( 'WANCache:m:' . $key, 1, 0 ); $checkKeys = [ wfRandomString() ]; // new check keys => force misses $ret = $cache->getWithSetCallback( $key, 30, $func, @@ -1048,14 +1111,14 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { $this->assertEquals( $busyValue, $ret, 'Callback was not used; used busy value' ); $this->assertEquals( 2, $calls, 'Callback was not used; used busy value' ); - $this->internalCache->delete( $cache::MUTEX_KEY_PREFIX . $key ); + $this->internalCache->delete( 'WANCache:m:' . $key ); $mockWallClock += 0.001; // cached values will be newer than tombstone $ret = $cache->getWithSetCallback( $key, 30, $func, [ 'lockTSE' => 30, 'busyValue' => $busyValue, 'checkKeys' => $checkKeys ] ); $this->assertEquals( $value, $ret, 'Callback was used; saved interim' ); $this->assertEquals( 3, $calls, 'Callback was used; saved interim' ); - $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 ); + $this->internalCache->add( 'WANCache:m:' . $key, 1, 0 ); $ret = $cache->getWithSetCallback( $key, 30, $func, [ 'busyValue' => $busyValue, 'checkKeys' => $checkKeys ] ); $this->assertEquals( $value, $ret, 'Callback was not used; used interim' ); @@ -1083,7 +1146,7 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { $cache->set( $key2, $value2, 10 ); $curTTLs = []; - $this->assertEquals( + $this->assertSame( [ $key1 => $value1, $key2 => $value2 ], $cache->getMulti( [ $key1, $key2, $key3 ], $curTTLs ), 'Result array populated' @@ -1099,7 +1162,7 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { $mockWallClock += 1; $curTTLs = []; - $this->assertEquals( + $this->assertSame( [ $key1 => $value1, $key2 => $value2 ], $cache->getMulti( [ $key1, $key2, $key3 ], $curTTLs, [ $cKey1, $cKey2 ] ), "Result array populated even with new check keys" @@ -1145,7 +1208,7 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { // Fake initial check key to be set in the past. Otherwise we'd have to sleep for // several seconds during the test to assert the behaviour. foreach ( [ $checkAll, $check1, $check2 ] as $checkKey ) { - $cache->touchCheckKey( $checkKey, WANObjectCache::HOLDOFF_NONE ); + $cache->touchCheckKey( $checkKey, WANObjectCache::HOLDOFF_TTL_NONE ); } $mockWallClock += 0.100; @@ -1160,7 +1223,7 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { 'key2' => $check2, 'key3' => $check3, ] ); - $this->assertEquals( + $this->assertSame( [ 'key1' => $value1, 'key2' => $value2 ], $result, 'Initial values' @@ -1180,7 +1243,7 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { 'key2' => $check2, 'key3' => $check3, ] ); - $this->assertEquals( + $this->assertSame( [ 'key1' => $value1, 'key2' => $value2 ], $result, 'key1 expired by check1, but value still provided' @@ -1267,7 +1330,7 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { $this->assertLessThan( 0, $curTTL, "Deleted key is tombstoned and has current TTL < 0" ); $this->cache->set( $key, $value ); - $this->cache->delete( $key, WANObjectCache::HOLDOFF_NONE ); + $this->cache->delete( $key, WANObjectCache::HOLDOFF_TTL_NONE ); $curTTL = null; $v = $this->cache->get( $key, $curTTL ); @@ -1283,7 +1346,7 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { /** * @dataProvider getWithSetCallback_versions_provider * @covers WANObjectCache::getWithSetCallback() - * @covers WANObjectCache::doGetWithSetCallback() + * @covers WANObjectCache::fetchOrRegenerate() * @param array $extOpts * @param bool $versioned */ @@ -1399,10 +1462,10 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { $v = $cache->getWithSetCallback( $key, 60, $func ); $this->assertEquals( 3, $wasCalled, 'Value regenerated (got mutex)' ); // sets interim // Lock up the mutex so interim cache is used - $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 ); + $this->internalCache->add( 'WANCache:m:' . $key, 1, 0 ); $v = $cache->getWithSetCallback( $key, 60, $func ); $this->assertEquals( 3, $wasCalled, 'Value interim cached (failed mutex)' ); - $this->internalCache->delete( $cache::MUTEX_KEY_PREFIX . $key ); + $this->internalCache->delete( 'WANCache:m:' . $key ); $cache->useInterimHoldOffCaching( false ); @@ -1419,7 +1482,7 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { $v = $cache->getWithSetCallback( $key, 60, $func ); $this->assertEquals( 4, $wasCalled, 'Value still regenerated (got mutex)' ); // Lock up the mutex so interim cache is used - $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 ); + $this->internalCache->add( 'WANCache:m:' . $key, 1, 0 ); $v = $cache->getWithSetCallback( $key, 60, $func ); $this->assertEquals( 5, $wasCalled, 'Value still regenerated (failed mutex)' ); } @@ -1485,16 +1548,16 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { // Two check keys are newer (given hold-off) than $key, another is older $this->internalCache->set( - WANObjectCache::TIME_KEY_PREFIX . $tKey2, - WANObjectCache::PURGE_VAL_PREFIX . ( $priorTime - 3 ) + 'WANCache:t:' . $tKey2, + 'PURGED:' . ( $priorTime - 3 ) ); $this->internalCache->set( - WANObjectCache::TIME_KEY_PREFIX . $tKey2, - WANObjectCache::PURGE_VAL_PREFIX . ( $priorTime - 5 ) + 'WANCache:t:' . $tKey2, + 'PURGED:' . ( $priorTime - 5 ) ); $this->internalCache->set( - WANObjectCache::TIME_KEY_PREFIX . $tKey1, - WANObjectCache::PURGE_VAL_PREFIX . ( $priorTime - 30 ) + 'WANCache:t:' . $tKey1, + 'PURGED:' . ( $priorTime - 30 ) ); $this->cache->set( $key, $value, 30 ); @@ -1521,30 +1584,30 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { $badTime = microtime( true ) - 300; $this->internalCache->set( - WANObjectCache::VALUE_KEY_PREFIX . $vKey1, + 'WANCache:v:' . $vKey1, [ - WANObjectCache::FLD_VERSION => WANObjectCache::VERSION, - WANObjectCache::FLD_VALUE => $value, - WANObjectCache::FLD_TTL => 3600, - WANObjectCache::FLD_TIME => $goodTime + 0 => 1, + 1 => $value, + 2 => 3600, + 3 => $goodTime ] ); $this->internalCache->set( - WANObjectCache::VALUE_KEY_PREFIX . $vKey2, + 'WANCache:v:' . $vKey2, [ - WANObjectCache::FLD_VERSION => WANObjectCache::VERSION, - WANObjectCache::FLD_VALUE => $value, - WANObjectCache::FLD_TTL => 3600, - WANObjectCache::FLD_TIME => $badTime + 0 => 1, + 1 => $value, + 2 => 3600, + 3 => $badTime ] ); $this->internalCache->set( - WANObjectCache::TIME_KEY_PREFIX . $tKey1, - WANObjectCache::PURGE_VAL_PREFIX . $goodTime + 'WANCache:t:' . $tKey1, + 'PURGED:' . $goodTime ); $this->internalCache->set( - WANObjectCache::TIME_KEY_PREFIX . $tKey2, - WANObjectCache::PURGE_VAL_PREFIX . $badTime + 'WANCache:t:' . $tKey2, + 'PURGED:' . $badTime ); $this->assertEquals( $value, $this->cache->get( $vKey1 ) ); @@ -1569,10 +1632,10 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { ->setMethods( [ 'get', 'changeTTL' ] )->getMock(); $backend->expects( $this->once() )->method( 'get' ) ->willReturn( [ - WANObjectCache::FLD_VERSION => WANObjectCache::VERSION, - WANObjectCache::FLD_VALUE => 'value', - WANObjectCache::FLD_TTL => 3600, - WANObjectCache::FLD_TIME => 300, + 0 => 1, + 1 => 'value', + 2 => 3600, + 3 => 300, ] ); $backend->expects( $this->once() )->method( 'changeTTL' ) ->willReturn( false ); @@ -1658,7 +1721,7 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { ] ); $localBag->expects( $this->once() )->method( 'set' ) - ->with( "/*/mw-wan/" . $wanCache::VALUE_KEY_PREFIX . "test" ); + ->with( "/*/mw-wan/" . 'WANCache:v:' . "test" ); $wanCache->delete( 'test' ); } @@ -1674,7 +1737,7 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { ] ); $localBag->expects( $this->once() )->method( 'set' ) - ->with( "/*/mw-wan/" . $wanCache::TIME_KEY_PREFIX . "test" ); + ->with( "/*/mw-wan/" . 'WANCache:t:' . "test" ); $wanCache->touchCheckKey( 'test' ); } @@ -1690,7 +1753,7 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { ] ); $localBag->expects( $this->once() )->method( 'delete' ) - ->with( "/*/mw-wan/" . $wanCache::TIME_KEY_PREFIX . "test" ); + ->with( "/*/mw-wan/" . 'WANCache:t:' . "test" ); $wanCache->resetCheckKey( 'test' ); } @@ -1802,6 +1865,10 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { * @covers WANObjectCache::makeKey */ public function testMakeKey() { + if ( defined( 'HHVM_VERSION' ) ) { + $this->markTestSkipped( 'HHVM Reflection buggy' ); + } + $backend = $this->getMockBuilder( HashBagOStuff::class ) ->setMethods( [ 'makeKey' ] )->getMock(); $backend->expects( $this->once() )->method( 'makeKey' ) @@ -1818,6 +1885,10 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { * @covers WANObjectCache::makeGlobalKey */ public function testMakeGlobalKey() { + if ( defined( 'HHVM_VERSION' ) ) { + $this->markTestSkipped( 'HHVM Reflection buggy' ); + } + $backend = $this->getMockBuilder( HashBagOStuff::class ) ->setMethods( [ 'makeGlobalKey' ] )->getMock(); $backend->expects( $this->once() )->method( 'makeGlobalKey' ) @@ -1850,6 +1921,137 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase { $this->assertEquals( $class, $wanCache->determineKeyClassForStats( $key ) ); } + + /** + * @covers WANObjectCache::makeMultiKeys + */ + public function testMakeMultiKeys() { + $cache = $this->cache; + + $ids = [ 1, 2, 3, 4, 4, 5, 6, 6, 7, 7 ]; + $keyCallback = function ( $id, WANObjectCache $cache ) { + return $cache->makeKey( 'key', $id ); + }; + $keyedIds = $cache->makeMultiKeys( $ids, $keyCallback ); + + $expected = [ + "local:key:1" => 1, + "local:key:2" => 2, + "local:key:3" => 3, + "local:key:4" => 4, + "local:key:5" => 5, + "local:key:6" => 6, + "local:key:7" => 7 + ]; + $this->assertSame( $expected, iterator_to_array( $keyedIds ) ); + + $ids = [ '1', '2', '3', '4', '4', '5', '6', '6', '7', '7' ]; + $keyCallback = function ( $id, WANObjectCache $cache ) { + return $cache->makeGlobalKey( 'key', $id, 'a', $id, 'b' ); + }; + $keyedIds = $cache->makeMultiKeys( $ids, $keyCallback ); + + $expected = [ + "global:key:1:a:1:b" => '1', + "global:key:2:a:2:b" => '2', + "global:key:3:a:3:b" => '3', + "global:key:4:a:4:b" => '4', + "global:key:5:a:5:b" => '5', + "global:key:6:a:6:b" => '6', + "global:key:7:a:7:b" => '7' + ]; + $this->assertSame( $expected, iterator_to_array( $keyedIds ) ); + } + + /** + * @covers WANObjectCache::makeMultiKeys + */ + public function testMakeMultiKeysIntString() { + $cache = $this->cache; + $ids = [ 1, 2, 3, 4, '4', 5, 6, 6, 7, '7' ]; + $keyCallback = function ( $id, WANObjectCache $cache ) { + return $cache->makeGlobalKey( 'key', $id, 'a', $id, 'b' ); + }; + + $keyedIds = $cache->makeMultiKeys( $ids, $keyCallback ); + + $expected = [ + "global:key:1:a:1:b" => 1, + "global:key:2:a:2:b" => 2, + "global:key:3:a:3:b" => 3, + "global:key:4:a:4:b" => 4, + "global:key:5:a:5:b" => 5, + "global:key:6:a:6:b" => 6, + "global:key:7:a:7:b" => 7 + ]; + $this->assertSame( $expected, iterator_to_array( $keyedIds ) ); + } + + /** + * @covers WANObjectCache::makeMultiKeys + * @expectedException UnexpectedValueException + */ + public function testMakeMultiKeysCollision() { + $ids = [ 1, 2, 3, 4, '4', 5, 6, 6, 7 ]; + + $this->cache->makeMultiKeys( + $ids, + function ( $id ) { + return "keymod:" . $id % 3; + } + ); + } + + /** + * @covers WANObjectCache::multiRemap + */ + public function testMultiRemap() { + $a = [ 'a', 'b', 'c' ]; + $res = [ 'keyA' => 1, 'keyB' => 2, 'keyC' => 3 ]; + + $this->assertEquals( + [ 'a' => 1, 'b' => 2, 'c' => 3 ], + $this->cache->multiRemap( $a, $res ) + ); + + $a = [ 'a', 'b', 'c', 'c', 'd' ]; + $res = [ 'keyA' => 1, 'keyB' => 2, 'keyC' => 3, 'keyD' => 4 ]; + + $this->assertEquals( + [ 'a' => 1, 'b' => 2, 'c' => 3, 'd' => 4 ], + $this->cache->multiRemap( $a, $res ) + ); + } + + /** + * @covers WANObjectCache::hash256 + */ + public function testHash256() { + $bag = new HashBagOStuff(); + $cache = new WANObjectCache( [ 'cache' => $bag, 'epoch' => 5 ] ); + $this->assertEquals( + 'f402bce76bfa1136adc705d8d5719911ce1fe61f0ad82ddf79a15f3c4de6ec4c', + $cache->hash256( 'x' ) + ); + + $cache = new WANObjectCache( [ 'cache' => $bag, 'epoch' => 50 ] ); + $this->assertEquals( + 'f79a126722f0a682c4c500509f1b61e836e56c4803f92edc89fc281da5caa54e', + $cache->hash256( 'x' ) + ); + + $cache = new WANObjectCache( [ 'cache' => $bag, 'secret' => 'garden' ] ); + $this->assertEquals( + '48cd57016ffe29981a1114c45e5daef327d30fc6206cb73edc3cb94b4d8fe093', + $cache->hash256( 'x' ) + ); + + $cache = new WANObjectCache( [ 'cache' => $bag, 'secret' => 'garden', 'epoch' => 3 ] ); + $this->assertEquals( + '48cd57016ffe29981a1114c45e5daef327d30fc6206cb73edc3cb94b4d8fe093', + $cache->hash256( 'x' ) + ); + } } class NearExpiringWANObjectCache extends WANObjectCache { diff --git a/tests/phpunit/includes/libs/rdbms/connectionmanager/ConnectionManagerTest.php b/tests/phpunit/includes/libs/rdbms/connectionmanager/ConnectionManagerTest.php index dd86a73eca..857f7090c9 100644 --- a/tests/phpunit/includes/libs/rdbms/connectionmanager/ConnectionManagerTest.php +++ b/tests/phpunit/includes/libs/rdbms/connectionmanager/ConnectionManagerTest.php @@ -13,6 +13,7 @@ use Wikimedia\Rdbms\ConnectionManager; * @author Daniel Kinzler */ class ConnectionManagerTest extends \PHPUnit\Framework\TestCase { + use \PHPUnit4And6Compat; /** * @return IDatabase|PHPUnit_Framework_MockObject_MockObject @@ -26,11 +27,7 @@ class ConnectionManagerTest extends \PHPUnit\Framework\TestCase { * @return LoadBalancer|PHPUnit_Framework_MockObject_MockObject */ private function getLoadBalancerMock() { - $lb = $this->getMockBuilder( LoadBalancer::class ) - ->disableOriginalConstructor() - ->getMock(); - - return $lb; + return $this->createMock( LoadBalancer::class ); } public function testGetReadConnection_nullGroups() { diff --git a/tests/phpunit/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManagerTest.php b/tests/phpunit/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManagerTest.php index 8d7d104c1e..3492c3d9bb 100644 --- a/tests/phpunit/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManagerTest.php +++ b/tests/phpunit/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManagerTest.php @@ -13,6 +13,7 @@ use Wikimedia\Rdbms\SessionConsistentConnectionManager; * @author Daniel Kinzler */ class SessionConsistentConnectionManagerTest extends \PHPUnit\Framework\TestCase { + use \PHPUnit4And6Compat; /** * @return IDatabase|PHPUnit_Framework_MockObject_MockObject @@ -26,11 +27,7 @@ class SessionConsistentConnectionManagerTest extends \PHPUnit\Framework\TestCase * @return LoadBalancer|PHPUnit_Framework_MockObject_MockObject */ private function getLoadBalancerMock() { - $lb = $this->getMockBuilder( LoadBalancer::class ) - ->disableOriginalConstructor() - ->getMock(); - - return $lb; + return $this->createMock( LoadBalancer::class ); } public function testGetReadConnection() { diff --git a/tests/phpunit/includes/libs/rdbms/database/DBConnRefTest.php b/tests/phpunit/includes/libs/rdbms/database/DBConnRefTest.php index 33e5c3b3fb..fafeb4e20d 100644 --- a/tests/phpunit/includes/libs/rdbms/database/DBConnRefTest.php +++ b/tests/phpunit/includes/libs/rdbms/database/DBConnRefTest.php @@ -1,11 +1,10 @@ getMockBuilder( Database::class ) + $db = $this->getMockBuilder( IDatabase::class ) ->disableOriginalConstructor() ->getMock(); @@ -60,12 +59,6 @@ class DBConnRefTest extends PHPUnit\Framework\TestCase { $db->method( 'isOpen' )->willReturnCallback( function () use ( &$open ) { return $open; } ); - $db->method( 'open' )->willReturnCallback( function () use ( &$open ) { - $open = true; - - return $open; - } ); - $db->method( '__toString' )->willReturn( 'MOCK_DB' ); return $db; } @@ -82,7 +75,7 @@ class DBConnRefTest extends PHPUnit\Framework\TestCase { $lb = $this->getLoadBalancerMock(); $ref = new DBConnRef( $lb, $this->getDatabaseMock(), DB_MASTER ); - $this->assertInstanceOf( ResultWrapper::class, $ref->select( 'whatever', '*' ) ); + $this->assertInstanceOf( IResultWrapper::class, $ref->select( 'whatever', '*' ) ); } public function testConstruct_params() { @@ -103,7 +96,7 @@ class DBConnRefTest extends PHPUnit\Framework\TestCase { DB_MASTER ); - $this->assertInstanceOf( ResultWrapper::class, $ref->select( 'whatever', '*' ) ); + $this->assertInstanceOf( IResultWrapper::class, $ref->select( 'whatever', '*' ) ); $this->assertEquals( DB_MASTER, $ref->getReferenceRole() ); $ref2 = new DBConnRef( @@ -126,7 +119,7 @@ class DBConnRefTest extends PHPUnit\Framework\TestCase { private function innerMethodForTestDestruct( ILoadBalancer $lb ) { $ref = $lb->getConnectionRef( DB_REPLICA ); - $this->assertInstanceOf( ResultWrapper::class, $ref->select( 'whatever', '*' ) ); + $this->assertInstanceOf( IResultWrapper::class, $ref->select( 'whatever', '*' ) ); } public function testConstruct_failure() { @@ -157,7 +150,7 @@ class DBConnRefTest extends PHPUnit\Framework\TestCase { public function testSelect() { // select should get passed through normally $ref = $this->getDBConnRef(); - $this->assertInstanceOf( ResultWrapper::class, $ref->select( 'whatever', '*' ) ); + $this->assertInstanceOf( IResultWrapper::class, $ref->select( 'whatever', '*' ) ); } public function testToString() { diff --git a/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php b/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php index c0d25553dc..58f6c05b10 100644 --- a/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php +++ b/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php @@ -1343,7 +1343,7 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { } /** - * @covers Wikimedia\Rdbms\Database::registerTempTableWrite + * @covers Wikimedia\Rdbms\Database::getTempWrites */ public function testSessionTempTables() { $temp1 = $this->database->tableName( 'tmp_table_1' ); @@ -1488,7 +1488,8 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { $triggerMap = [ '-' => '-', IDatabase::TRIGGER_COMMIT => 'tCommit', - IDatabase::TRIGGER_ROLLBACK => 'tRollback' + IDatabase::TRIGGER_ROLLBACK => 'tRollback', + IDatabase::TRIGGER_CANCEL => 'tCancel', ]; $pcCallback = function ( IDatabase $db ) use ( $fname ) { $this->database->query( "SELECT 0", $fname ); @@ -1518,6 +1519,11 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { $this->database->cancelAtomic( __METHOD__ ); $this->assertLastSql( 'BEGIN; ROLLBACK; SELECT 1, tRollback AS t' ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onAtomicSectionCancel( $callback1, __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ ); + $this->assertLastSql( 'BEGIN; ROLLBACK; SELECT 1, tRollback AS t' ); + $this->database->startAtomic( __METHOD__ . '_outer' ); $this->database->onTransactionPreCommitOrIdle( $pcCallback, __METHOD__ ); $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); @@ -1567,6 +1573,21 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { 'SELECT 3, tCommit AS t' ] ) ); + $this->database->startAtomic( __METHOD__ . '_outer' ); + $this->database->onAtomicSectionCancel( $callback1, __METHOD__ ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onAtomicSectionCancel( $callback2, __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->onAtomicSectionCancel( $callback3, __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + $this->assertLastSql( implode( "; ", [ + 'BEGIN', + 'SAVEPOINT wikimedia_rdbms_atomic1', + 'ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1', + 'SELECT 2, tCancel AS t', + 'COMMIT', + ] ) ); + $makeCallback = function ( $id ) use ( $fname, $triggerMap ) { return function ( $trigger = '-' ) use ( $id, $fname, $triggerMap ) { $this->database->query( "SELECT $id, {$triggerMap[$trigger]} AS t", $fname ); @@ -1609,6 +1630,29 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { 'SELECT 3, tRollback AS t', 'SELECT 4, tCommit AS t' ] ) ); + + $this->database->startAtomic( __METHOD__ . '_level1', IDatabase::ATOMIC_CANCELABLE ); + $this->database->onAtomicSectionCancel( $makeCallback( 1 ), __METHOD__ ); + $this->database->startAtomic( __METHOD__ . '_level2' ); + $this->database->startAtomic( __METHOD__ . '_level3', IDatabase::ATOMIC_CANCELABLE ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onAtomicSectionCancel( $makeCallback( 2 ), __METHOD__ ); + $this->database->endAtomic( __METHOD__ ); + $this->database->onAtomicSectionCancel( $makeCallback( 3 ), __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ . '_level3' ); + $this->database->endAtomic( __METHOD__ . '_level2' ); + $this->database->onAtomicSectionCancel( $makeCallback( 4 ), __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_level1' ); + $this->assertLastSql( implode( "; ", [ + 'BEGIN', + 'SAVEPOINT wikimedia_rdbms_atomic1', + 'SAVEPOINT wikimedia_rdbms_atomic2', + 'RELEASE SAVEPOINT wikimedia_rdbms_atomic2', + 'ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1', + 'SELECT 2, tCancel AS t', + 'SELECT 3, tCancel AS t', + 'COMMIT', + ] ) ); } /** @@ -1658,7 +1702,7 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { $this->fail( 'Test exception not thrown' ); } catch ( DBTransactionError $ex ) { $this->assertSame( - 'Cannot execute query from ' . __METHOD__ . ' while transaction status is ERROR.', + 'Cannot execute query from ' . __METHOD__ . ' while transaction status is ERROR', $ex->getMessage() ); } @@ -1692,6 +1736,16 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { $callback3Called = $trigger; $this->database->query( "SELECT 3", $fname ); }; + $callback4Called = 0; + $callback4 = function () use ( $fname, &$callback4Called ) { + $callback4Called++; + $this->database->query( "SELECT 4", $fname ); + }; + $callback5Called = 0; + $callback5 = function () use ( $fname, &$callback5Called ) { + $callback5Called++; + $this->database->query( "SELECT 5", $fname ); + }; $this->database->startAtomic( __METHOD__ . '_outer' ); $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); @@ -1699,63 +1753,73 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ ); $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ ); $this->database->onTransactionResolution( $callback3, __METHOD__ ); + $this->database->onAtomicSectionCancel( $callback4, __METHOD__ ); $this->database->endAtomic( __METHOD__ . '_inner' ); $this->database->cancelAtomic( __METHOD__ ); $this->database->endAtomic( __METHOD__ . '_outer' ); $this->assertNull( $callback1Called ); $this->assertNull( $callback2Called ); $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called ); + $this->assertEquals( 1, $callback4Called ); // phpcs:ignore Generic.Files.LineLength - $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; COMMIT; SELECT 3' ); + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; SELECT 4; COMMIT; SELECT 3' ); $callback1Called = null; $callback2Called = null; $callback3Called = null; + $callback4Called = 0; $this->database->startAtomic( __METHOD__ . '_outer' ); $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); $this->database->startAtomic( __METHOD__ . '_inner', IDatabase::ATOMIC_CANCELABLE ); $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ ); $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ ); $this->database->onTransactionResolution( $callback3, __METHOD__ ); + $this->database->onAtomicSectionCancel( $callback4, __METHOD__ ); $this->database->endAtomic( __METHOD__ . '_inner' ); $this->database->cancelAtomic( __METHOD__ ); $this->database->endAtomic( __METHOD__ . '_outer' ); $this->assertNull( $callback1Called ); $this->assertNull( $callback2Called ); $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called ); + $this->assertEquals( 1, $callback4Called ); // phpcs:ignore Generic.Files.LineLength - $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; SAVEPOINT wikimedia_rdbms_atomic2; RELEASE SAVEPOINT wikimedia_rdbms_atomic2; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; COMMIT; SELECT 3' ); + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; SAVEPOINT wikimedia_rdbms_atomic2; RELEASE SAVEPOINT wikimedia_rdbms_atomic2; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; SELECT 4; COMMIT; SELECT 3' ); $callback1Called = null; $callback2Called = null; $callback3Called = null; + $callback4Called = 0; $this->database->startAtomic( __METHOD__ . '_outer' ); $atomicId = $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); $this->database->startAtomic( __METHOD__ . '_inner' ); $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ ); $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ ); $this->database->onTransactionResolution( $callback3, __METHOD__ ); + $this->database->onAtomicSectionCancel( $callback4, __METHOD__ ); $this->database->cancelAtomic( __METHOD__, $atomicId ); $this->database->endAtomic( __METHOD__ . '_outer' ); $this->assertNull( $callback1Called ); $this->assertNull( $callback2Called ); $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called ); + $this->assertEquals( 1, $callback4Called ); $callback1Called = null; $callback2Called = null; $callback3Called = null; + $callback4Called = 0; $this->database->startAtomic( __METHOD__ . '_outer' ); $atomicId = $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); $this->database->startAtomic( __METHOD__ . '_inner' ); $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ ); $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ ); $this->database->onTransactionResolution( $callback3, __METHOD__ ); + $this->database->onAtomicSectionCancel( $callback4, __METHOD__ ); try { $this->database->cancelAtomic( __METHOD__ . '_X', $atomicId ); } catch ( DBUnexpectedError $e ) { $m = __METHOD__; $this->assertSame( - "Invalid atomic section ended (got {$m}_X but expected {$m}).", + "Invalid atomic section ended (got {$m}_X but expected {$m})", $e->getMessage() ); } @@ -1764,30 +1828,65 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { $this->assertNull( $callback1Called ); $this->assertNull( $callback2Called ); $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called ); + $this->assertEquals( 1, $callback4Called ); + $callback4Called = 0; + $callback5Called = 0; + $this->database->getLastSqls(); // flush $this->database->startAtomic( __METHOD__ . '_outer' ); $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); - $this->database->startAtomic( __METHOD__ . '_inner' ); - $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ ); - $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ ); - $this->database->onTransactionResolution( $callback3, __METHOD__ ); + $this->database->onAtomicSectionCancel( $callback5, __METHOD__ ); + $this->database->startAtomic( __METHOD__ . '_inner', IDatabase::ATOMIC_CANCELABLE ); + $this->database->onAtomicSectionCancel( $callback4, __METHOD__ ); $this->database->cancelAtomic( __METHOD__ . '_inner' ); $this->database->cancelAtomic( __METHOD__ ); $this->database->endAtomic( __METHOD__ . '_outer' ); - $this->assertNull( $callback1Called ); - $this->assertNull( $callback2Called ); - $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; SAVEPOINT wikimedia_rdbms_atomic2; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic2; SELECT 4; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; SELECT 5; COMMIT' ); + $this->assertEquals( 1, $callback4Called ); + $this->assertEquals( 1, $callback5Called ); + + $callback4Called = 0; + $callback5Called = 0; + $this->database->startAtomic( __METHOD__ . '_outer' ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onAtomicSectionCancel( $callback5, __METHOD__ ); + $this->database->startAtomic( __METHOD__ . '_inner', IDatabase::ATOMIC_CANCELABLE ); + $this->database->onAtomicSectionCancel( $callback4, __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_inner' ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; SAVEPOINT wikimedia_rdbms_atomic2; RELEASE SAVEPOINT wikimedia_rdbms_atomic2; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; SELECT 5; SELECT 4; COMMIT' ); + $this->assertEquals( 1, $callback4Called ); + $this->assertEquals( 1, $callback5Called ); + + $callback4Called = 0; + $callback5Called = 0; + $this->database->startAtomic( __METHOD__ . '_outer' ); + $sectionId = $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onAtomicSectionCancel( $callback5, __METHOD__ ); + $this->database->startAtomic( __METHOD__ . '_inner', IDatabase::ATOMIC_CANCELABLE ); + $this->database->onAtomicSectionCancel( $callback4, __METHOD__ ); + $this->database->cancelAtomic( __METHOD__, $sectionId ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; SAVEPOINT wikimedia_rdbms_atomic2; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; SELECT 5; SELECT 4; COMMIT' ); + $this->assertEquals( 1, $callback4Called ); + $this->assertEquals( 1, $callback5Called ); $wrapper = TestingAccessWrapper::newFromObject( $this->database ); $callback1Called = null; $callback2Called = null; $callback3Called = null; + $callback4Called = 0; $this->database->startAtomic( __METHOD__ . '_outer' ); $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); $this->database->startAtomic( __METHOD__ . '_inner' ); $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ ); $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ ); $this->database->onTransactionResolution( $callback3, __METHOD__ ); + $this->database->onAtomicSectionCancel( $callback4, __METHOD__ ); $wrapper->trxStatus = Database::STATUS_TRX_ERROR; $this->database->cancelAtomic( __METHOD__ . '_inner' ); $this->database->cancelAtomic( __METHOD__ ); @@ -1795,6 +1894,7 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { $this->assertNull( $callback1Called ); $this->assertNull( $callback2Called ); $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called ); + $this->assertEquals( 1, $callback4Called ); } /** @@ -1834,7 +1934,7 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { $this->fail( 'Expected exception not thrown' ); } catch ( DBUnexpectedError $ex ) { $this->assertSame( - 'No atomic section is open (got ' . __METHOD__ . ').', + 'No atomic section is open (got ' . __METHOD__ . ')', $ex->getMessage() ); } @@ -1853,7 +1953,7 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { } catch ( DBUnexpectedError $ex ) { $this->assertSame( 'Invalid atomic section ended (got ' . __METHOD__ . ' but expected ' . - __METHOD__ . 'X).', + __METHOD__ . 'X)', $ex->getMessage() ); } @@ -1870,7 +1970,23 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { $this->fail( 'Expected exception not thrown' ); } catch ( DBTransactionError $ex ) { $this->assertSame( - 'Cannot execute query from ' . __METHOD__ . ' while transaction status is ERROR.', + 'Cannot execute query from ' . __METHOD__ . ' while transaction status is ERROR', + $ex->getMessage() + ); + } + } + + /** + * @covers \Wikimedia\Rdbms\Database::onAtomicSectionCancel + */ + public function testNoAtomicSectionForCallback() { + try { + $this->database->onAtomicSectionCancel( function () { + }, __METHOD__ ); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBUnexpectedError $ex ) { + $this->assertSame( + 'No atomic section is open (got ' . __METHOD__ . ')', $ex->getMessage() ); } @@ -1878,7 +1994,7 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { /** * @expectedException \Wikimedia\Rdbms\DBTransactionStateError - * @covers \Wikimedia\Rdbms\Database::assertTransactionStatus + * @covers \Wikimedia\Rdbms\Database::assertQueryIsCurrentlyAllowed */ public function testTransactionErrorState1() { $wrapper = TestingAccessWrapper::newFromObject( $this->database ); @@ -1958,7 +2074,7 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { $this->fail( 'Expected exception not thrown' ); } catch ( DBTransactionError $e ) { $this->assertEquals( - 'Cannot execute query from ' . __METHOD__ . ' while transaction status is ERROR.', + 'Cannot execute query from ' . __METHOD__ . ' while transaction status is ERROR', $e->getMessage() ); } @@ -1967,7 +2083,7 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { $this->fail( 'Expected exception not thrown' ); } catch ( DBTransactionError $e ) { $this->assertEquals( - 'Cannot execute query from ' . __METHOD__ . ' while transaction status is ERROR.', + 'Cannot execute query from ' . __METHOD__ . ' while transaction status is ERROR', $e->getMessage() ); } @@ -2071,7 +2187,7 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { $this->fail( 'Expected exception not thrown' ); } catch ( DBUnexpectedError $ex ) { $this->assertSame( - "Wikimedia\Rdbms\Database::close: transaction is still open (from $fname).", + "Wikimedia\Rdbms\Database::close: transaction is still open (from $fname)", $ex->getMessage() ); } @@ -2091,19 +2207,22 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { $this->database->onTransactionCommitOrIdle( function () use ( $fname ) { $this->database->query( 'SELECT 1', $fname ); } ); + $this->database->onAtomicSectionCancel( function () use ( $fname ) { + $this->database->query( 'SELECT 2', $fname ); + } ); $this->database->delete( 'x', [ 'field' => 3 ], __METHOD__ ); $this->database->close(); $this->fail( 'Expected exception not thrown' ); } catch ( DBUnexpectedError $ex ) { $this->assertSame( 'Wikimedia\Rdbms\Database::close: atomic sections ' . - 'DatabaseSQLTest::testPrematureClose2 are still open.', + 'DatabaseSQLTest::testPrematureClose2 are still open', $ex->getMessage() ); } $this->assertFalse( $this->database->isOpen() ); - $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'3\'; ROLLBACK' ); + $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'3\'; ROLLBACK; SELECT 2' ); $this->assertEquals( 0, $this->database->trxLevel() ); } @@ -2120,7 +2239,7 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { } catch ( DBUnexpectedError $ex ) { $this->assertSame( 'Wikimedia\Rdbms\Database::close: ' . - 'mass commit/rollback of peer transaction required (DBO_TRX set).', + 'mass commit/rollback of peer transaction required (DBO_TRX set)', $ex->getMessage() ); } diff --git a/tests/phpunit/includes/libs/rdbms/database/DatabaseTest.php b/tests/phpunit/includes/libs/rdbms/database/DatabaseTest.php index 8b24791ca6..482ab4b5f5 100644 --- a/tests/phpunit/includes/libs/rdbms/database/DatabaseTest.php +++ b/tests/phpunit/includes/libs/rdbms/database/DatabaseTest.php @@ -704,4 +704,31 @@ class DatabaseTest extends PHPUnit\Framework\TestCase { $this->assertSame( $oldDomain, $this->db->getDomainId() ); } + /** + * @covers Wikimedia\Rdbms\Database::getLBInfo + * @covers Wikimedia\Rdbms\Database::setLBInfo + */ + public function testGetSetLBInfo() { + $db = $this->getMockDB(); + + $this->assertEquals( [], $db->getLBInfo() ); + $this->assertNull( $db->getLBInfo( 'pringles' ) ); + + $db->setLBInfo( 'soda', 'water' ); + $this->assertEquals( [ 'soda' => 'water' ], $db->getLBInfo() ); + $this->assertNull( $db->getLBInfo( 'pringles' ) ); + $this->assertEquals( 'water', $db->getLBInfo( 'soda' ) ); + + $db->setLBInfo( 'basketball', 'Lebron' ); + $this->assertEquals( [ 'soda' => 'water', 'basketball' => 'Lebron' ], $db->getLBInfo() ); + $this->assertEquals( 'water', $db->getLBInfo( 'soda' ) ); + $this->assertEquals( 'Lebron', $db->getLBInfo( 'basketball' ) ); + + $db->setLBInfo( 'soda', null ); + $this->assertEquals( [ 'basketball' => 'Lebron' ], $db->getLBInfo() ); + + $db->setLBInfo( [ 'King' => 'James' ] ); + $this->assertNull( $db->getLBInfo( 'basketball' ) ); + $this->assertEquals( [ 'King' => 'James' ], $db->getLBInfo() ); + } } diff --git a/tests/phpunit/includes/libs/rdbms/resultwrapper/FakeResultWrapperTest.php b/tests/phpunit/includes/libs/rdbms/resultwrapper/FakeResultWrapperTest.php new file mode 100644 index 0000000000..cecdc715ac --- /dev/null +++ b/tests/phpunit/includes/libs/rdbms/resultwrapper/FakeResultWrapperTest.php @@ -0,0 +1,69 @@ + 1, 'colB' => 'a' ], + [ 'colA' => 2, 'colB' => 'b' ], + (object)[ 'colA' => 3, 'colB' => 'c' ], + [ 'colA' => 4, 'colB' => 'd' ], + [ 'colA' => 5, 'colB' => 'e' ], + (object)[ 'colA' => 6, 'colB' => 'f' ], + (object)[ 'colA' => 7, 'colB' => 'g' ], + [ 'colA' => 8, 'colB' => 'h' ] + ] ); + + $expectedRows = [ + 0 => (object)[ 'colA' => 1, 'colB' => 'a' ], + 1 => (object)[ 'colA' => 2, 'colB' => 'b' ], + 2 => (object)[ 'colA' => 3, 'colB' => 'c' ], + 3 => (object)[ 'colA' => 4, 'colB' => 'd' ], + 4 => (object)[ 'colA' => 5, 'colB' => 'e' ], + 5 => (object)[ 'colA' => 6, 'colB' => 'f' ], + 6 => (object)[ 'colA' => 7, 'colB' => 'g' ], + 7 => (object)[ 'colA' => 8, 'colB' => 'h' ] + ]; + + $this->assertEquals( 8, $res->numRows() ); + + $res->seek( 7 ); + $this->assertEquals( [ 'colA' => 8, 'colB' => 'h' ], $res->fetchRow() ); + $res->seek( 7 ); + $this->assertEquals( (object)[ 'colA' => 8, 'colB' => 'h' ], $res->fetchObject() ); + + $this->assertEquals( $expectedRows, iterator_to_array( $res, true ) ); + + $rows = []; + foreach ( $res as $i => $row ) { + $rows[$i] = $row; + } + $this->assertEquals( $expectedRows, $rows ); + } +} diff --git a/tests/phpunit/includes/libs/rdbms/resultwrapper/ResultWrapperTest.php b/tests/phpunit/includes/libs/rdbms/resultwrapper/ResultWrapperTest.php new file mode 100644 index 0000000000..ae619668ba --- /dev/null +++ b/tests/phpunit/includes/libs/rdbms/resultwrapper/ResultWrapperTest.php @@ -0,0 +1,112 @@ +getMockBuilder( IDatabase::class ) + ->disableOriginalConstructor() + ->getMock(); + $db->method( 'select' )->willReturnCallback( + function () use ( $db, $rows ) { + return new ResultWrapper( $db, $rows ); + } + ); + $db->method( 'dataSeek' )->willReturnCallback( + function ( ResultWrapper $res, $pos ) use ( $db ) { + // Position already set in ResultWrapper + } + ); + $db->method( 'fetchRow' )->willReturnCallback( + function ( ResultWrapper $res ) use ( $db ) { + $row = $res::unwrap( $res )[$res->key()] ?? false; + + return $row; + } + ); + $db->method( 'fetchObject' )->willReturnCallback( + function ( ResultWrapper $res ) use ( $db ) { + $row = $res::unwrap( $res )[$res->key()] ?? false; + + return $row ? (object)$row : false; + } + ); + $db->method( 'numRows' )->willReturnCallback( + function ( ResultWrapper $res ) use ( $db ) { + return count( $res::unwrap( $res ) ); + } + ); + + return $db; + } + + public function testIteration() { + $db = $this->getDatabaseMock( [ + [ 'colA' => 1, 'colB' => 'a' ], + [ 'colA' => 2, 'colB' => 'b' ], + [ 'colA' => 3, 'colB' => 'c' ], + [ 'colA' => 4, 'colB' => 'd' ], + [ 'colA' => 5, 'colB' => 'e' ], + [ 'colA' => 6, 'colB' => 'f' ], + [ 'colA' => 7, 'colB' => 'g' ], + [ 'colA' => 8, 'colB' => 'h' ] + ] ); + + $expectedRows = [ + 0 => (object)[ 'colA' => 1, 'colB' => 'a' ], + 1 => (object)[ 'colA' => 2, 'colB' => 'b' ], + 2 => (object)[ 'colA' => 3, 'colB' => 'c' ], + 3 => (object)[ 'colA' => 4, 'colB' => 'd' ], + 4 => (object)[ 'colA' => 5, 'colB' => 'e' ], + 5 => (object)[ 'colA' => 6, 'colB' => 'f' ], + 6 => (object)[ 'colA' => 7, 'colB' => 'g' ], + 7 => (object)[ 'colA' => 8, 'colB' => 'h' ] + ]; + + $res = $db->select( 'faketable', [ 'colA', 'colB' ], '1 = 1', __METHOD__ ); + $this->assertEquals( 8, $res->numRows() ); + + $res->seek( 7 ); + $this->assertEquals( [ 'colA' => 8, 'colB' => 'h' ], $res->fetchRow() ); + $res->seek( 7 ); + $this->assertEquals( (object)[ 'colA' => 8, 'colB' => 'h' ], $res->fetchObject() ); + + $this->assertEquals( $expectedRows, iterator_to_array( $res, true ) ); + + $rows = []; + foreach ( $res as $i => $row ) { + $rows[$i] = $row; + } + $this->assertEquals( $expectedRows, $rows ); + } +} diff --git a/tests/phpunit/includes/linker/LinkRendererTest.php b/tests/phpunit/includes/linker/LinkRendererTest.php index d4e1961efe..b26a247c80 100644 --- a/tests/phpunit/includes/linker/LinkRendererTest.php +++ b/tests/phpunit/includes/linker/LinkRendererTest.php @@ -138,9 +138,10 @@ class LinkRendererTest extends MediaWikiLangTestCase { } public function testGetLinkClasses() { - $wanCache = ObjectCache::getMainWANInstance(); - $titleFormatter = MediaWikiServices::getInstance()->getTitleFormatter(); - $nsInfo = MediaWikiServices::getInstance()->getNamespaceInfo(); + $services = MediaWikiServices::getInstance(); + $wanCache = $services->getMainWANObjectCache(); + $titleFormatter = $services->getTitleFormatter(); + $nsInfo = $services->getNamespaceInfo(); $linkCache = new LinkCache( $titleFormatter, $wanCache, $nsInfo ); $foobarTitle = new TitleValue( NS_MAIN, 'FooBar' ); $redirectTitle = new TitleValue( NS_MAIN, 'Redirect' ); diff --git a/tests/phpunit/includes/logging/BlockLogFormatterTest.php b/tests/phpunit/includes/logging/BlockLogFormatterTest.php index bc0ca2ad85..b6f8f9cc37 100644 --- a/tests/phpunit/includes/logging/BlockLogFormatterTest.php +++ b/tests/phpunit/includes/logging/BlockLogFormatterTest.php @@ -34,6 +34,30 @@ class BlockLogFormatterTest extends LogFormatterTestCase { 'duration' => 'infinite', 'flags' => [ 'anononly' ], ], + 'preload' => [ new TitleValue( NS_USER_TALK, 'Logtestuser' ) ], + ], + ], + + // With blank page title (T224811) + [ + [ + 'type' => 'block', + 'action' => 'block', + 'comment' => 'Block comment', + 'user' => 0, + 'user_text' => 'Sysop', + 'namespace' => NS_USER, + 'title' => '', + 'params' => [], + ], + [ + 'text' => 'Sysop blocked (no username available) ' + . 'with an expiration time of indefinite', + 'api' => [ + 'duration' => 'infinite', + 'flags' => [], + ], + 'preload' => [], ], ], diff --git a/tests/phpunit/includes/logging/DeleteLogFormatterTest.php b/tests/phpunit/includes/logging/DeleteLogFormatterTest.php index 0e6855d9a9..6648c31c25 100644 --- a/tests/phpunit/includes/logging/DeleteLogFormatterTest.php +++ b/tests/phpunit/includes/logging/DeleteLogFormatterTest.php @@ -457,7 +457,7 @@ class DeleteLogFormatterTest extends LogFormatterTestCase { ], ], - // Legacy format + // Legacy formats [ [ 'type' => 'suppress', @@ -495,6 +495,27 @@ class DeleteLogFormatterTest extends LogFormatterTestCase { ], ], ], + [ + [ + 'type' => 'delete', + 'action' => 'revision', + 'comment' => 'Old rows might lack ofield/nfield (T224815)', + 'namespace' => NS_MAIN, + 'title' => 'Page', + 'params' => [ + 'oldid', + '1234', + ], + ], + [ + 'legacy' => true, + 'text' => 'User changed visibility of revisions on page Page', + 'api' => [ + 'type' => 'oldid', + 'ids' => [ '1234' ], + ], + ], + ] ]; } diff --git a/tests/phpunit/includes/logging/LogFormatterTest.php b/tests/phpunit/includes/logging/LogFormatterTest.php index f444d40c3e..4bb9d5ab1a 100644 --- a/tests/phpunit/includes/logging/LogFormatterTest.php +++ b/tests/phpunit/includes/logging/LogFormatterTest.php @@ -1,5 +1,7 @@ assertEquals( $expected, $logParam ); } + /** + * @covers LogFormatter::newFromEntry + * @covers LogFormatter::getActionText + */ + public function testLogParamsTypeUserLink_empty() { + $params = [ '4:user-link:userLink' => ':' ]; + + $entry = $this->newLogEntry( 'param', $params ); + $formatter = LogFormatter::newFromEntry( $entry ); + + $this->context->setLanguage( Language::factory( 'qqx' ) ); + $formatter->setContext( $this->context ); + + $logParam = $formatter->getActionText(); + $this->assertContains( '(empty-username)', $logParam ); + } + /** * @covers LogFormatter::newFromEntry * @covers LogFormatter::getActionText @@ -248,6 +267,20 @@ class LogFormatterTest extends MediaWikiLangTestCase { $this->assertEquals( $expected, $logParam ); } + /** + * @covers LogFormatter::getPerformerElement + */ + public function testGetPerformerElement() { + $entry = $this->newLogEntry( 'param', [] ); + $entry->setPerformer( new UserIdentityValue( 1328435, 'Test', 0 ) ); + + $formatter = LogFormatter::newFromEntry( $entry ); + $formatter->setContext( $this->context ); + + $element = $formatter->getPerformerElement(); + $this->assertContains( 'User:Test', $element ); + } + /** * @covers LogFormatter::newFromEntry * @covers LogFormatter::getComment diff --git a/tests/phpunit/includes/logging/LogFormatterTestCase.php b/tests/phpunit/includes/logging/LogFormatterTestCase.php index 883af71240..fc2ab916cb 100644 --- a/tests/phpunit/includes/logging/LogFormatterTestCase.php +++ b/tests/phpunit/includes/logging/LogFormatterTestCase.php @@ -1,4 +1,5 @@ formatParametersForApi() ), 'Api log params is equal to expected array' ); + + if ( isset( $extra['preload'] ) ) { + $this->assertArrayEquals( + $this->getLinkTargetsAsStrings( $extra['preload'] ), + $this->getLinkTargetsAsStrings( + $formatter->getPreloadTitles() + ) + ); + } + } + + private function getLinkTargetsAsStrings( array $linkTargets ) { + return array_map( function ( LinkTarget $t ) { + return $t->getInterwiki() . ':' . $t->getNamespace() . ':' + . $t->getDBkey() . '#' . $t->getFragment(); + }, $linkTargets ); } protected function isLegacy( $extra ) { diff --git a/tests/phpunit/includes/media/BitmapMetadataHandlerTest.php b/tests/phpunit/includes/media/BitmapMetadataHandlerTest.php index 83554d28e1..32a6b6ac0a 100644 --- a/tests/phpunit/includes/media/BitmapMetadataHandlerTest.php +++ b/tests/phpunit/includes/media/BitmapMetadataHandlerTest.php @@ -77,21 +77,18 @@ class BitmapMetadataHandlerTest extends MediaWikiTestCase { $meta = BitmapMetadataHandler::Jpeg( $this->filePath . 'iptc-timetest.jpg' ); + // raw date is 2020:07:13 14:04:05+11:32 $this->assertEquals( '2020:07:14 01:36:05', $meta['DateTimeDigitized'] ); + // raw date is 1997:03:02 03:01:02-03:00 $this->assertEquals( '1997:03:02 00:01:02', $meta['DateTimeOriginal'] ); - } - /** - * File has an invalid time (+ one valid but really weird time) - * that shouldn't be included - * @covers BitmapMetadataHandler::Jpeg - */ - public function testIPTCDatesInvalid() { $meta = BitmapMetadataHandler::Jpeg( $this->filePath . 'iptc-timetest-invalid.jpg' ); + // raw date is 1845:03:02 03:01:02-03:00 $this->assertEquals( '1845:03:02 00:01:02', $meta['DateTimeOriginal'] ); - $this->assertFalse( isset( $meta['DateTimeDigitized'] ) ); + // raw date is 1942:07:13 25:05:02+00:00 + $this->assertSame( '1942:07:14 01:05:02', $meta['DateTimeDigitized'] ); } /** diff --git a/tests/phpunit/includes/media/GIFMetadataExtractorTest.php b/tests/phpunit/includes/media/GIFMetadataExtractorTest.php deleted file mode 100644 index 278b441bbd..0000000000 --- a/tests/phpunit/includes/media/GIFMetadataExtractorTest.php +++ /dev/null @@ -1,110 +0,0 @@ -mediaPath = __DIR__ . '/../../data/media/'; - } - - /** - * Put in a file, and see if the metadata coming out is as expected. - * @param string $filename - * @param array $expected The extracted metadata. - * @dataProvider provideGetMetadata - * @covers GIFMetadataExtractor::getMetadata - */ - public function testGetMetadata( $filename, $expected ) { - $actual = GIFMetadataExtractor::getMetadata( $this->mediaPath . $filename ); - $this->assertEquals( $expected, $actual ); - } - - public static function provideGetMetadata() { - $xmpNugget = << - - - - - The interwebs - - - - Bawolff - - - A file to test GIF - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -EOF; - $xmpNugget = str_replace( "\r", '', $xmpNugget ); // Windows compat - - return [ - [ - 'nonanimated.gif', - [ - 'comment' => [ 'GIF test file ⁕ Created with GIMP' ], - 'duration' => 0.1, - 'frameCount' => 1, - 'looped' => false, - 'xmp' => '', - ] - ], - [ - 'animated.gif', - [ - 'comment' => [ 'GIF test file . Created with GIMP' ], - 'duration' => 2.4, - 'frameCount' => 4, - 'looped' => true, - 'xmp' => '', - ] - ], - - [ - 'animated-xmp.gif', - [ - 'xmp' => $xmpNugget, - 'duration' => 2.4, - 'frameCount' => 4, - 'looped' => true, - 'comment' => [ 'GIƒ·test·file' ], - ] - ], - ]; - } -} diff --git a/tests/phpunit/includes/media/IPTCTest.php b/tests/phpunit/includes/media/IPTCTest.php deleted file mode 100644 index 4b3ba0755c..0000000000 --- a/tests/phpunit/includes/media/IPTCTest.php +++ /dev/null @@ -1,85 +0,0 @@ -assertEquals( 'UTF-8', $res ); - } - - /** - * @covers IPTC::parse - */ - public function testIPTCParseNoCharset88591() { - // basically IPTC for keyword with value of 0xBC which is 1/4 in iso-8859-1 - // This data doesn't specify a charset. We're supposed to guess - // (which basically means utf-8 if valid, windows 1252 (iso 8859-1) if not) - $iptcData = "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x06\x1c\x02\x19\x00\x01\xBC"; - $res = IPTC::parse( $iptcData ); - $this->assertEquals( [ '¼' ], $res['Keywords'] ); - } - - /** - * @covers IPTC::parse - */ - public function testIPTCParseNoCharset88591b() { - /* This one contains a sequence that's valid iso 8859-1 but not valid utf8 */ - /* \xC3 = Ã, \xB8 = ¸ */ - $iptcData = "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x09\x1c\x02\x19\x00\x04\xC3\xC3\xC3\xB8"; - $res = IPTC::parse( $iptcData ); - $this->assertEquals( [ 'ÃÃø' ], $res['Keywords'] ); - } - - /** - * Same as testIPTCParseNoCharset88591b, but forcing the charset to utf-8. - * What should happen is the first "\xC3\xC3" should be dropped as invalid, - * leaving \xC3\xB8, which is ø - * @covers IPTC::parse - */ - public function testIPTCParseForcedUTFButInvalid() { - $iptcData = "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x11\x1c\x02\x19\x00\x04\xC3\xC3\xC3\xB8" - . "\x1c\x01\x5A\x00\x03\x1B\x25\x47"; - $res = IPTC::parse( $iptcData ); - $this->assertEquals( [ 'ø' ], $res['Keywords'] ); - } - - /** - * @covers IPTC::parse - */ - public function testIPTCParseNoCharsetUTF8() { - $iptcData = "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x07\x1c\x02\x19\x00\x02¼"; - $res = IPTC::parse( $iptcData ); - $this->assertEquals( [ '¼' ], $res['Keywords'] ); - } - - /** - * Testing something that has 2 values for keyword - * @covers IPTC::parse - */ - public function testIPTCParseMulti() { - $iptcData = /* identifier */ "Photoshop 3.0\08BIM\4\4" - /* length */ . "\0\0\0\0\0\x0D" - . "\x1c\x02\x19" . "\x00\x01" . "\xBC" - . "\x1c\x02\x19" . "\x00\x02" . "\xBC\xBD"; - $res = IPTC::parse( $iptcData ); - $this->assertEquals( [ '¼', '¼½' ], $res['Keywords'] ); - } - - /** - * @covers IPTC::parse - */ - public function testIPTCParseUTF8() { - // This has the magic "\x1c\x01\x5A\x00\x03\x1B\x25\x47" which marks content as UTF8. - $iptcData = - "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x0F\x1c\x02\x19\x00\x02¼\x1c\x01\x5A\x00\x03\x1B\x25\x47"; - $res = IPTC::parse( $iptcData ); - $this->assertEquals( [ '¼' ], $res['Keywords'] ); - } -} diff --git a/tests/phpunit/includes/media/JpegMetadataExtractorTest.php b/tests/phpunit/includes/media/JpegMetadataExtractorTest.php deleted file mode 100644 index c943cef906..0000000000 --- a/tests/phpunit/includes/media/JpegMetadataExtractorTest.php +++ /dev/null @@ -1,128 +0,0 @@ -filePath = __DIR__ . '/../../data/media/'; - } - - /** - * We also use this test to test padding bytes don't - * screw stuff up - * - * @param string $file Filename - * - * @dataProvider provideUtf8Comment - */ - public function testUtf8Comment( $file ) { - $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . $file ); - $this->assertEquals( [ 'UTF-8 JPEG Comment — ¼' ], $res['COM'] ); - } - - public static function provideUtf8Comment() { - return [ - [ 'jpeg-comment-utf.jpg' ], - [ 'jpeg-padding-even.jpg' ], - [ 'jpeg-padding-odd.jpg' ], - ]; - } - - /** The file is iso-8859-1, but it should get auto converted */ - public function testIso88591Comment() { - $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-comment-iso8859-1.jpg' ); - $this->assertEquals( [ 'ISO-8859-1 JPEG Comment - ¼' ], $res['COM'] ); - } - - /** Comment values that are non-textual (random binary junk) should not be shown. - * The example test file has a comment with a 0x5 byte in it which is a control character - * and considered binary junk for our purposes. - */ - public function testBinaryCommentStripped() { - $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-comment-binary.jpg' ); - $this->assertEmpty( $res['COM'] ); - } - - /* Very rarely a file can have multiple comments. - * Order of comments is based on order inside the file. - */ - public function testMultipleComment() { - $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-comment-multiple.jpg' ); - $this->assertEquals( [ 'foo', 'bar' ], $res['COM'] ); - } - - public function testXMPExtraction() { - $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-xmp-psir.jpg' ); - $expected = file_get_contents( $this->filePath . 'jpeg-xmp-psir.xmp' ); - $this->assertEquals( $expected, $res['XMP'] ); - } - - public function testPSIRExtraction() { - $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-xmp-psir.jpg' ); - $expected = '50686f746f73686f7020332e30003842494d04040000000' - . '000181c02190004746573741c02190003666f6f1c020000020004'; - $this->assertEquals( $expected, bin2hex( $res['PSIR'][0] ) ); - } - - public function testXMPExtractionAltAppId() { - $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-xmp-alt.jpg' ); - $expected = file_get_contents( $this->filePath . 'jpeg-xmp-psir.xmp' ); - $this->assertEquals( $expected, $res['XMP'] ); - } - - public function testIPTCHashComparisionNoHash() { - $segments = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-xmp-psir.jpg' ); - $res = JpegMetadataExtractor::doPSIR( $segments['PSIR'][0] ); - - $this->assertEquals( 'iptc-no-hash', $res ); - } - - public function testIPTCHashComparisionBadHash() { - $segments = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-iptc-bad-hash.jpg' ); - $res = JpegMetadataExtractor::doPSIR( $segments['PSIR'][0] ); - - $this->assertEquals( 'iptc-bad-hash', $res ); - } - - public function testIPTCHashComparisionGoodHash() { - $segments = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-iptc-good-hash.jpg' ); - $res = JpegMetadataExtractor::doPSIR( $segments['PSIR'][0] ); - - $this->assertEquals( 'iptc-good-hash', $res ); - } - - public function testExifByteOrder() { - $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'exif-user-comment.jpg' ); - $expected = 'BE'; - $this->assertEquals( $expected, $res['byteOrder'] ); - } - - public function testInfiniteRead() { - // test file truncated right after a segment, which previously - // caused an infinite loop looking for the next segment byte. - // Should get past infinite loop and throw in wfUnpack() - $this->setExpectedException( 'MWException' ); - $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-segment-loop1.jpg' ); - } - - public function testInfiniteRead2() { - // test file truncated after a segment's marker and size, which - // would cause a seek past end of file. Seek past end of file - // doesn't actually fail, but prevents further reading and was - // devolving into the previous case (testInfiniteRead). - $this->setExpectedException( 'MWException' ); - $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-segment-loop2.jpg' ); - } -} diff --git a/tests/phpunit/includes/media/MediaHandlerTest.php b/tests/phpunit/includes/media/MediaHandlerTest.php deleted file mode 100644 index 7a052f6035..0000000000 --- a/tests/phpunit/includes/media/MediaHandlerTest.php +++ /dev/null @@ -1,68 +0,0 @@ -assertEquals( $expected, - $result, - "($width, $height, $max) wanted: {$expected}x$y, got: {z$result}x$y2" ); - } - - public static function provideTestFitBoxWidth() { - return array_merge( - static::generateTestFitBoxWidthData( 50, 50, [ - 50 => 50, - 17 => 17, - 18 => 18 ] - ), - static::generateTestFitBoxWidthData( 366, 300, [ - 50 => 61, - 17 => 21, - 18 => 22 ] - ), - static::generateTestFitBoxWidthData( 300, 366, [ - 50 => 41, - 17 => 14, - 18 => 15 ] - ), - static::generateTestFitBoxWidthData( 100, 400, [ - 50 => 12, - 17 => 4, - 18 => 4 ] - ) - ); - } - - /** - * Generate single test cases by combining the dimensions and tests contents - * - * It creates: - * [$width, $height, $max, $expected], - * [$width, $height, $max2, $expected2], ... - * out of parameters: - * $width, $height, { $max => $expected, $max2 => $expected2, ... } - * - * @param int $width - * @param int $height - * @param array $tests associative array of $max => $expected values - * @return array - */ - private static function generateTestFitBoxWidthData( $width, $height, $tests ) { - $result = []; - foreach ( $tests as $max => $expected ) { - $result[] = [ $width, $height, $max, $expected ]; - } - return $result; - } -} diff --git a/tests/phpunit/includes/media/SVGMetadataExtractorTest.php b/tests/phpunit/includes/media/SVGMetadataExtractorTest.php index 6b94d0ae6c..c84efa1640 100644 --- a/tests/phpunit/includes/media/SVGMetadataExtractorTest.php +++ b/tests/phpunit/includes/media/SVGMetadataExtractorTest.php @@ -4,7 +4,7 @@ * @group Media * @covers SVGMetadataExtractor */ -class SVGMetadataExtractorTest extends MediaWikiTestCase { +class SVGMetadataExtractorTest extends \MediaWikiIntegrationTestCase { /** * @dataProvider provideSvgFiles diff --git a/tests/phpunit/includes/objectcache/MemcachedBagOStuffTest.php b/tests/phpunit/includes/objectcache/MemcachedBagOStuffTest.php deleted file mode 100644 index 432754b602..0000000000 --- a/tests/phpunit/includes/objectcache/MemcachedBagOStuffTest.php +++ /dev/null @@ -1,107 +0,0 @@ -cache = new MemcachedBagOStuff( [ 'keyspace' => 'test' ] ); - } - - /** - * @covers MemcachedBagOStuff::makeKey - */ - public function testKeyNormalization() { - $this->assertEquals( - 'test:vanilla', - $this->cache->makeKey( 'vanilla' ) - ); - - $this->assertEquals( - 'test:punctuation_marks_are_ok:!@$^&*()', - $this->cache->makeKey( 'punctuation_marks_are_ok', '!@$^&*()' ) - ); - - $this->assertEquals( - 'test:but_spaces:hashes%23:and%0Anewlines:are_not', - $this->cache->makeKey( 'but spaces', 'hashes#', "and\nnewlines", 'are_not' ) - ); - - $this->assertEquals( - 'test:this:key:contains:%F0%9D%95%9E%F0%9D%95%A6%F0%9D%95%9D%F0%9D%95%A5%F0%9' . - 'D%95%9A%F0%9D%95%93%F0%9D%95%AA%F0%9D%95%A5%F0%9D%95%96:characters', - $this->cache->makeKey( 'this', 'key', 'contains', '𝕞𝕦𝕝𝕥𝕚𝕓𝕪𝕥𝕖', 'characters' ) - ); - - $this->assertEquals( - 'test:this:key:contains:#c118f92685a635cb843039de50014c9c', - $this->cache->makeKey( 'this', 'key', 'contains', '𝕥𝕠𝕠 𝕞𝕒𝕟𝕪 𝕞𝕦𝕝𝕥𝕚𝕓𝕪𝕥𝕖 𝕔𝕙𝕒𝕣𝕒𝕔𝕥𝕖𝕣𝕤' ) - ); - - $this->assertEquals( - 'test:BagOStuff-long-key:##dc89dcb43b28614da27660240af478b5', - $this->cache->makeKey( '𝕖𝕧𝕖𝕟', '𝕚𝕗', '𝕨𝕖', '𝕄𝔻𝟝', '𝕖𝕒𝕔𝕙', - '𝕒𝕣𝕘𝕦𝕞𝕖𝕟𝕥', '𝕥𝕙𝕚𝕤', '𝕜𝕖𝕪', '𝕨𝕠𝕦𝕝𝕕', '𝕤𝕥𝕚𝕝𝕝', '𝕓𝕖', '𝕥𝕠𝕠', '𝕝𝕠𝕟𝕘' ) - ); - - $this->assertEquals( - 'test:%23%235820ad1d105aa4dc698585c39df73e19', - $this->cache->makeKey( '##5820ad1d105aa4dc698585c39df73e19' ) - ); - - $this->assertEquals( - 'test:percent_is_escaped:!@$%25^&*()', - $this->cache->makeKey( 'percent_is_escaped', '!@$%^&*()' ) - ); - - $this->assertEquals( - 'test:colon_is_escaped:!@$%3A^&*()', - $this->cache->makeKey( 'colon_is_escaped', '!@$:^&*()' ) - ); - - $this->assertEquals( - 'test:long_key_part_hashed:#0244f7b1811d982dd932dd7de01465ac', - $this->cache->makeKey( 'long_key_part_hashed', str_repeat( 'y', 500 ) ) - ); - } - - /** - * @dataProvider validKeyProvider - * @covers MemcachedBagOStuff::validateKeyEncoding - */ - public function testValidateKeyEncoding( $key ) { - $this->assertSame( $key, $this->cache->validateKeyEncoding( $key ) ); - } - - public function validKeyProvider() { - return [ - 'empty' => [ '' ], - 'digits' => [ '09' ], - 'letters' => [ 'AZaz' ], - 'ASCII special characters' => [ '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~' ], - ]; - } - - /** - * @dataProvider invalidKeyProvider - * @covers MemcachedBagOStuff::validateKeyEncoding - */ - public function testValidateKeyEncodingThrowsException( $key ) { - $this->setExpectedException( Exception::class ); - $this->cache->validateKeyEncoding( $key ); - } - - public function invalidKeyProvider() { - return [ - [ "\x00" ], - [ ' ' ], - [ "\x1F" ], - [ "\x7F" ], - [ "\x80" ], - [ "\xFF" ], - ]; - } -} diff --git a/tests/phpunit/includes/objectcache/RESTBagOStuffTest.php b/tests/phpunit/includes/objectcache/RESTBagOStuffTest.php deleted file mode 100644 index dfbca706d6..0000000000 --- a/tests/phpunit/includes/objectcache/RESTBagOStuffTest.php +++ /dev/null @@ -1,96 +0,0 @@ -client = - $this->getMockBuilder( MultiHttpClient::class ) - ->setConstructorArgs( [ [] ] ) - ->setMethods( [ 'run' ] ) - ->getMock(); - $this->bag = new RESTBagOStuff( [ 'client' => $this->client, 'url' => 'http://test/rest/' ] ); - } - - public function testGet() { - $this->client->expects( $this->once() )->method( 'run' )->with( [ - 'method' => 'GET', - 'url' => 'http://test/rest/42xyz42', - 'headers' => [] - // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) - ] )->willReturn( [ 200, 'OK', [], '"somedata"', 0 ] ); - $result = $this->bag->get( '42xyz42' ); - $this->assertEquals( 'somedata', $result ); - } - - public function testGetNotExist() { - $this->client->expects( $this->once() )->method( 'run' )->with( [ - 'method' => 'GET', - 'url' => 'http://test/rest/42xyz42', - 'headers' => [] - // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) - ] )->willReturn( [ 404, 'Not found', [], 'Nothing to see here', 0 ] ); - $result = $this->bag->get( '42xyz42' ); - $this->assertFalse( $result ); - } - - public function testGetBadClient() { - $this->client->expects( $this->once() )->method( 'run' )->with( [ - 'method' => 'GET', - 'url' => 'http://test/rest/42xyz42', - 'headers' => [] - // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) - ] )->willReturn( [ 0, '', [], '', 'cURL has failed you today' ] ); - $result = $this->bag->get( '42xyz42' ); - $this->assertFalse( $result ); - $this->assertEquals( BagOStuff::ERR_UNREACHABLE, $this->bag->getLastError() ); - } - - public function testGetBadServer() { - $this->client->expects( $this->once() )->method( 'run' )->with( [ - 'method' => 'GET', - 'url' => 'http://test/rest/42xyz42', - 'headers' => [] - // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) - ] )->willReturn( [ 500, 'Too busy', [], 'Server is too busy', '' ] ); - $result = $this->bag->get( '42xyz42' ); - $this->assertFalse( $result ); - $this->assertEquals( BagOStuff::ERR_UNEXPECTED, $this->bag->getLastError() ); - } - - public function testPut() { - $this->client->expects( $this->once() )->method( 'run' )->with( [ - 'method' => 'PUT', - 'url' => 'http://test/rest/42xyz42', - 'body' => '"postdata"', - 'headers' => [] - // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) - ] )->willReturn( [ 200, 'OK', [], 'Done', 0 ] ); - $result = $this->bag->set( '42xyz42', 'postdata' ); - $this->assertTrue( $result ); - } - - public function testDelete() { - $this->client->expects( $this->once() )->method( 'run' )->with( [ - 'method' => 'DELETE', - 'url' => 'http://test/rest/42xyz42', - 'headers' => [] - // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) - ] )->willReturn( [ 200, 'OK', [], 'Done', 0 ] ); - $result = $this->bag->delete( '42xyz42' ); - $this->assertTrue( $result ); - } -} diff --git a/tests/phpunit/includes/objectcache/RedisBagOStuffTest.php b/tests/phpunit/includes/objectcache/RedisBagOStuffTest.php deleted file mode 100644 index df5614d8c1..0000000000 --- a/tests/phpunit/includes/objectcache/RedisBagOStuffTest.php +++ /dev/null @@ -1,110 +0,0 @@ -getMockBuilder( RedisBagOStuff::class ) - ->disableOriginalConstructor() - ->getMock(); - $this->cache = TestingAccessWrapper::newFromObject( $cache ); - } - - /** - * @covers RedisBagOStuff::unserialize - * @dataProvider unserializeProvider - */ - public function testUnserialize( $expected, $input, $message ) { - $actual = $this->cache->unserialize( $input ); - $this->assertSame( $expected, $actual, $message ); - } - - public function unserializeProvider() { - return [ - [ - -1, - '-1', - 'String representation of \'-1\'', - ], - [ - 0, - '0', - 'String representation of \'0\'', - ], - [ - 1, - '1', - 'String representation of \'1\'', - ], - [ - -1.0, - 'd:-1;', - 'Serialized negative double', - ], - [ - 'foo', - 's:3:"foo";', - 'Serialized string', - ] - ]; - } - - /** - * @covers RedisBagOStuff::serialize - * @dataProvider serializeProvider - */ - public function testSerialize( $expected, $input, $message ) { - $actual = $this->cache->serialize( $input ); - $this->assertSame( $expected, $actual, $message ); - } - - public function serializeProvider() { - return [ - [ - -1, - -1, - '-1 as integer', - ], - [ - 0, - 0, - '0 as integer', - ], - [ - 1, - 1, - '1 as integer', - ], - [ - 'd:-1;', - -1.0, - 'Negative double', - ], - [ - 's:3:"2.1";', - '2.1', - 'Decimal string', - ], - [ - 's:1:"1";', - '1', - 'String representation of 1', - ], - [ - 's:3:"foo";', - 'foo', - 'String', - ], - ]; - } -} diff --git a/tests/phpunit/includes/page/ArticleTablesTest.php b/tests/phpunit/includes/page/ArticleTablesTest.php index 34b25251c1..5c5ce5c292 100644 --- a/tests/phpunit/includes/page/ArticleTablesTest.php +++ b/tests/phpunit/includes/page/ArticleTablesTest.php @@ -4,6 +4,7 @@ * @group Database */ class ArticleTablesTest extends MediaWikiLangTestCase { + /** * Make sure that T16404 doesn't strike again. We don't want * templatelinks based on the user language when {{int:}} is used, only the @@ -16,7 +17,7 @@ class ArticleTablesTest extends MediaWikiLangTestCase { $title = Title::newFromText( 'T16404' ); $page = WikiPage::factory( $title ); $user = new User(); - $user->mRights = [ 'createpage', 'edit', 'purge' ]; + $this->overrideUserPermissions( $user, [ 'createpage', 'edit', 'purge' ] ); $this->setContentLang( 'es' ); $this->setUserLang( 'fr' ); diff --git a/tests/phpunit/includes/page/ArticleTest.php b/tests/phpunit/includes/page/ArticleTest.php deleted file mode 100644 index df4a281701..0000000000 --- a/tests/phpunit/includes/page/ArticleTest.php +++ /dev/null @@ -1,57 +0,0 @@ -title = Title::makeTitle( NS_MAIN, 'SomePage' ); - $this->article = new Article( $this->title ); - } - - /** cleanup title object and its article object */ - protected function tearDown() { - parent::tearDown(); - $this->title = null; - $this->article = null; - } - - /** - * @covers Article::__get - */ - public function testImplementsGetMagic() { - $this->assertEquals( false, $this->article->mLatest, "Article __get magic" ); - } - - /** - * @depends testImplementsGetMagic - * @covers Article::__set - */ - public function testImplementsSetMagic() { - $this->article->mLatest = 2; - $this->assertEquals( 2, $this->article->mLatest, "Article __set magic" ); - } - - /** - * @covers Article::__get - * @covers Article::__set - */ - public function testGetOrSetOnNewProperty() { - $this->article->ext_someNewProperty = 12; - $this->assertEquals( 12, $this->article->ext_someNewProperty, - "Article get/set magic on new field" ); - - $this->article->ext_someNewProperty = -8; - $this->assertEquals( -8, $this->article->ext_someNewProperty, - "Article get/set magic on update to new field" ); - } -} diff --git a/tests/phpunit/includes/page/WikiPageDbTestBase.php b/tests/phpunit/includes/page/WikiPageDbTestBase.php index 3a3feeea11..ee6c227b60 100644 --- a/tests/phpunit/includes/page/WikiPageDbTestBase.php +++ b/tests/phpunit/includes/page/WikiPageDbTestBase.php @@ -1374,6 +1374,7 @@ more stuff // Now, try the rollback $admin->addGroup( 'sysop' ); // Make the test user a sysop + MediaWikiServices::getInstance()->getPermissionManager()->invalidateUsersRightsCache(); $token = $admin->getEditToken( 'rollback' ); $errors = $page->doRollback( $secondUser->getName(), diff --git a/tests/phpunit/includes/parser/CoreParserFunctionsTest.php b/tests/phpunit/includes/parser/CoreParserFunctionsTest.php index c630447751..ef2f2196fa 100644 --- a/tests/phpunit/includes/parser/CoreParserFunctionsTest.php +++ b/tests/phpunit/includes/parser/CoreParserFunctionsTest.php @@ -1,9 +1,11 @@ assertEquals( $msg, 'f', 'Works escaped' ); } + public function provideTalkpagename() { + yield [ 'Talk:Foo bar', 'foo_bar' ]; + yield [ 'Talk:Foo', ' foo ' ]; + yield [ 'Talk:Foo', 'Talk:Foo' ]; + yield [ 'User talk:Foo', 'User:foo' ]; + yield [ '', 'Special:Foo' ]; + yield [ '', '' ]; + yield [ '', ' ' ]; + yield [ '', '__' ]; + yield [ '', '#xyzzy' ]; + yield [ '', '#' ]; + yield [ '', ':' ]; + yield [ '', ':#' ]; + yield [ '', 'User:' ]; + yield [ '', 'User:#' ]; + } + + /** + * @dataProvider provideTalkpagename + */ + public function testTalkpagename( $expected, $title ) { + $parser = MediaWikiServices::getInstance()->getParser(); + + $this->assertSame( $expected, CoreParserFunctions::talkpagename( $parser, $title ) ); + } + + public function provideSubjectpagename() { + yield [ 'Foo bar', 'Talk:foo_bar' ]; + yield [ 'Foo', ' Talk:foo ' ]; + yield [ 'User:Foo', 'User talk:foo' ]; + yield [ 'Special:Foo', 'Special:Foo' ]; + yield [ '', '' ]; + yield [ '', ' ' ]; + yield [ '', '__' ]; + yield [ '', '#xyzzy' ]; + yield [ '', '#' ]; + yield [ '', ':' ]; + yield [ '', ':#' ]; + yield [ '', 'Talk:' ]; + yield [ '', 'User talk:#' ]; + yield [ '', 'User:#' ]; + } + + /** + * @dataProvider provideTalkpagename + */ + public function testSubjectpagename( $expected, $title ) { + $parser = MediaWikiServices::getInstance()->getParser(); + + $this->assertSame( $expected, CoreParserFunctions::talkpagename( $parser, $title ) ); + } + } diff --git a/tests/phpunit/includes/parser/ParserOutputTest.php b/tests/phpunit/includes/parser/ParserOutputTest.php index 812702b6ae..34ddb1f1fd 100644 --- a/tests/phpunit/includes/parser/ParserOutputTest.php +++ b/tests/phpunit/includes/parser/ParserOutputTest.php @@ -877,7 +877,7 @@ EOF $bClocks = $b->mParseStartTime; - $a->mergeInternalMetaDataFrom( $b->object, 'b' ); + $a->mergeInternalMetaDataFrom( $b->object ); $mergedClocks = $a->mParseStartTime; foreach ( $mergedClocks as $clock => $timestamp ) { @@ -890,7 +890,7 @@ EOF $a->resetParseStartTime(); $aClocks = $a->mParseStartTime; - $a->mergeInternalMetaDataFrom( $b->object, 'b' ); + $a->mergeInternalMetaDataFrom( $b->object ); $mergedClocks = $a->mParseStartTime; foreach ( $mergedClocks as $clock => $timestamp ) { @@ -902,7 +902,7 @@ EOF $a = new ParserOutput(); $a = TestingAccessWrapper::newFromObject( $a ); - $a->mergeInternalMetaDataFrom( $b->object, 'b' ); + $a->mergeInternalMetaDataFrom( $b->object ); $mergedClocks = $a->mParseStartTime; foreach ( $mergedClocks as $clock => $timestamp ) { diff --git a/tests/phpunit/includes/parser/PreprocessorTest.php b/tests/phpunit/includes/parser/PreprocessorTest.php index 6b3e05da51..3b2b1050bd 100644 --- a/tests/phpunit/includes/parser/PreprocessorTest.php +++ b/tests/phpunit/includes/parser/PreprocessorTest.php @@ -48,6 +48,9 @@ class PreprocessorTest extends MediaWikiTestCase { $this->mOptions = ParserOptions::newFromUserAndLang( new User, MediaWikiServices::getInstance()->getContentLanguage() ); + # Suppress deprecation warning for Preprocessor_DOM while testing + $this->hideDeprecated( 'Preprocessor_DOM::__construct' ); + $this->mPreprocessors = []; foreach ( self::$classNames as $className ) { $this->mPreprocessors[$className] = new $className( $this ); diff --git a/tests/phpunit/includes/parser/SanitizerTest.php b/tests/phpunit/includes/parser/SanitizerTest.php index 1b67bbdf79..99e8fb7ebd 100644 --- a/tests/phpunit/includes/parser/SanitizerTest.php +++ b/tests/phpunit/includes/parser/SanitizerTest.php @@ -1,9 +1,8 @@ assertEquals( - "\xc3\xa9cole", - Sanitizer::decodeCharReferences( 'école' ), - 'decode named entities' - ); - } - - /** - * @covers Sanitizer::decodeCharReferences - */ - public function testDecodeNumericEntities() { - $this->assertEquals( - "\xc4\x88io bonas dans l'\xc3\xa9cole!", - Sanitizer::decodeCharReferences( "Ĉio bonas dans l'école!" ), - 'decode numeric entities' - ); - } - - /** - * @covers Sanitizer::decodeCharReferences - */ - public function testDecodeMixedEntities() { - $this->assertEquals( - "\xc4\x88io bonas dans l'\xc3\xa9cole!", - Sanitizer::decodeCharReferences( "Ĉio bonas dans l'école!" ), - 'decode mixed numeric/named entities' - ); - } - - /** - * @covers Sanitizer::decodeCharReferences - */ - public function testDecodeMixedComplexEntities() { - $this->assertEquals( - "\xc4\x88io bonas dans l'\xc3\xa9cole! (mais pas Ĉio dans l'école)", - Sanitizer::decodeCharReferences( - "Ĉio bonas dans l'école! (mais pas &#x108;io dans l'&eacute;cole)" - ), - 'decode mixed complex entities' - ); - } - - /** - * @covers Sanitizer::decodeCharReferences - */ - public function testInvalidAmpersand() { - $this->assertEquals( - 'a & b', - Sanitizer::decodeCharReferences( 'a & b' ), - 'Invalid ampersand' - ); - } - - /** - * @covers Sanitizer::decodeCharReferences - */ - public function testInvalidEntities() { - $this->assertEquals( - '&foo;', - Sanitizer::decodeCharReferences( '&foo;' ), - 'Invalid named entity' - ); - } - - /** - * @covers Sanitizer::decodeCharReferences - */ - public function testInvalidNumberedEntities() { - $this->assertEquals( - UtfNormal\Constants::UTF8_REPLACEMENT, - Sanitizer::decodeCharReferences( "�" ), - 'Invalid numbered entity' - ); - } - /** * @covers Sanitizer::removeHTMLtags * @dataProvider provideHtml5Tags @@ -170,85 +90,11 @@ class SanitizerTest extends MediaWikiTestCase { $this->assertEquals( $output, Sanitizer::removeHTMLtags( $input ), $msg ); } - /** - * @dataProvider provideTagAttributesToDecode - * @covers Sanitizer::decodeTagAttributes - */ - public function testDecodeTagAttributes( $expected, $attributes, $message = '' ) { - $this->assertEquals( $expected, - Sanitizer::decodeTagAttributes( $attributes ), - $message - ); - } - - public static function provideTagAttributesToDecode() { - return [ - [ [ 'foo' => 'bar' ], 'foo=bar', 'Unquoted attribute' ], - [ [ 'עברית' => 'bar' ], 'עברית=bar', 'Non-Latin attribute' ], - [ [ '६' => 'bar' ], '६=bar', 'Devanagari number' ], - [ [ '搭𨋢' => 'bar' ], '搭𨋢=bar', 'Non-BMP character' ], - [ [], 'ńgh=bar', 'Combining accent is not allowed' ], - [ [ 'foo' => 'bar' ], ' foo = bar ', 'Spaced attribute' ], - [ [ 'foo' => 'bar' ], 'foo="bar"', 'Double-quoted attribute' ], - [ [ 'foo' => 'bar' ], 'foo=\'bar\'', 'Single-quoted attribute' ], - [ - [ 'foo' => 'bar', 'baz' => 'foo' ], - 'foo=\'bar\' baz="foo"', - 'Several attributes' - ], - [ - [ 'foo' => 'bar', 'baz' => 'foo' ], - 'foo=\'bar\' baz="foo"', - 'Several attributes' - ], - [ - [ 'foo' => 'bar', 'baz' => 'foo' ], - 'foo=\'bar\' baz="foo"', - 'Several attributes' - ], - [ [ ':foo' => 'bar' ], ':foo=\'bar\'', 'Leading :' ], - [ [ '_foo' => 'bar' ], '_foo=\'bar\'', 'Leading _' ], - [ [ 'foo' => 'bar' ], 'Foo=\'bar\'', 'Leading capital' ], - [ [ 'foo' => 'BAR' ], 'FOO=BAR', 'Attribute keys are normalized to lowercase' ], - - # Invalid beginning - [ [], '-foo=bar', 'Leading - is forbidden' ], - [ [], '.foo=bar', 'Leading . is forbidden' ], - [ [ 'foo-bar' => 'bar' ], 'foo-bar=bar', 'A - is allowed inside the attribute' ], - [ [ 'foo-' => 'bar' ], 'foo-=bar', 'A - is allowed inside the attribute' ], - [ [ 'foo.bar' => 'baz' ], 'foo.bar=baz', 'A . is allowed inside the attribute' ], - [ [ 'foo.' => 'baz' ], 'foo.=baz', 'A . is allowed as last character' ], - [ [ 'foo6' => 'baz' ], 'foo6=baz', 'Numbers are allowed' ], - - # This bit is more relaxed than XML rules, but some extensions use - # it, like ProofreadPage (see T29539) - [ [ '1foo' => 'baz' ], '1foo=baz', 'Leading numbers are allowed' ], - [ [], 'foo$=baz', 'Symbols are not allowed' ], - [ [], 'foo@=baz', 'Symbols are not allowed' ], - [ [], 'foo~=baz', 'Symbols are not allowed' ], - [ - [ 'foo' => '1[#^`*%w/(' ], - 'foo=1[#^`*%w/(', - 'All kind of characters are allowed as values' - ], - [ - [ 'foo' => '1[#^`*%\'w/(' ], - 'foo="1[#^`*%\'w/("', - 'Double quotes are allowed if quoted by single quotes' - ], - [ - [ 'foo' => '1[#^`*%"w/(' ], - 'foo=\'1[#^`*%"w/(\'', - 'Single quotes are allowed if quoted by double quotes' - ], - [ [ 'foo' => '&"' ], 'foo=&"', 'Special chars can be provided as entities' ], - [ [ 'foo' => '&foobar;' ], 'foo=&foobar;', 'Entity-like items are accepted' ], - ]; - } - /** * @dataProvider provideDeprecatedAttributes * @covers Sanitizer::fixTagAttributes + * @covers Sanitizer::validateTagAttributes + * @covers Sanitizer::validateAttributes */ public function testDeprecatedAttributesUnaltered( $inputAttr, $inputEl, $message = '' ) { $this->assertEquals( " $inputAttr", @@ -275,159 +121,55 @@ class SanitizerTest extends MediaWikiTestCase { } /** - * @dataProvider provideCssCommentsFixtures - * @covers Sanitizer::checkCss + * @dataProvider provideValidateTagAttributes + * @covers Sanitizer::validateTagAttributes + * @covers Sanitizer::validateAttributes */ - public function testCssCommentsChecking( $expected, $css, $message = '' ) { - $this->assertEquals( $expected, - Sanitizer::checkCss( $css ), - $message - ); + public function testValidateTagAttributes( $element, $attribs, $expected ) { + $actual = Sanitizer::validateTagAttributes( $attribs, $element ); + $this->assertArrayEquals( $expected, $actual, false, true ); } - public static function provideCssCommentsFixtures() { - /** [ , , [message] ] */ + public static function provideValidateTagAttributes() { return [ - // Valid comments spanning entire input - [ '/**/', '/**/' ], - [ '/* comment */', '/* comment */' ], - // Weird stuff - [ ' ', '/****/' ], - [ ' ', '/* /* */' ], - [ 'display: block;', "display:/* foo */block;" ], - [ 'display: block;', "display:\\2f\\2a foo \\2a\\2f block;", - 'Backslash-escaped comments must be stripped (T30450)' ], - [ '', '/* unfinished comment structure', - 'Remove anything after a comment-start token' ], - [ '', "\\2f\\2a unifinished comment'", - 'Remove anything after a backslash-escaped comment-start token' ], - [ - '/* insecure input */', - 'filter: progid:DXImageTransform.Microsoft.AlphaImageLoader' - . '(src=\'asdf.png\',sizingMethod=\'scale\');' + [ 'math', + [ 'id' => 'foo bar', 'bogus' => 'stripped', 'data-foo' => 'bar' ], + [ 'id' => 'foo_bar', 'data-foo' => 'bar' ], ], - [ - '/* insecure input */', - '-ms-filter: "progid:DXImageTransform.Microsoft.AlphaImageLoader' - . '(src=\'asdf.png\',sizingMethod=\'scale\')";' - ], - [ '/* insecure input */', 'width: expression(1+1);' ], - [ '/* insecure input */', 'background-image: image(asdf.png);' ], - [ '/* insecure input */', 'background-image: -webkit-image(asdf.png);' ], - [ '/* insecure input */', 'background-image: -moz-image(asdf.png);' ], - [ '/* insecure input */', 'background-image: image-set("asdf.png" 1x, "asdf.png" 2x);' ], - [ - '/* insecure input */', - 'background-image: -webkit-image-set("asdf.png" 1x, "asdf.png" 2x);' - ], - [ - '/* insecure input */', - 'background-image: -moz-image-set("asdf.png" 1x, "asdf.png" 2x);' + [ 'meta', + [ 'id' => 'foo bar', 'itemprop' => 'foo', 'content' => 'bar' ], + [ 'itemprop' => 'foo', 'content' => 'bar' ], ], - [ '/* insecure input */', 'foo: attr( title, url );' ], - [ '/* insecure input */', 'foo: attr( title url );' ], - [ '/* insecure input */', 'foo: var(--evil-attribute)' ], ]; } /** - * @dataProvider provideEscapeHtmlAllowEntities - * @covers Sanitizer::escapeHtmlAllowEntities + * @dataProvider provideAttributeWhitelist + * @covers Sanitizer::attributeWhitelist */ - public function testEscapeHtmlAllowEntities( $expected, $html ) { - $this->assertEquals( - $expected, - Sanitizer::escapeHtmlAllowEntities( $html ) - ); - } - - public static function provideEscapeHtmlAllowEntities() { - return [ - [ 'foo', 'foo' ], - [ 'a¡b', 'a¡b' ], - [ 'foo'bar', "foo'bar" ], - [ '<script>foo</script>', '' ], - ]; - } - - /** - * Test Sanitizer::escapeId - * - * @dataProvider provideEscapeId - * @covers Sanitizer::escapeId - */ - public function testEscapeId( $input, $output ) { - $this->assertEquals( - $output, - Sanitizer::escapeId( $input, [ 'noninitial', 'legacy' ] ) - ); - } - - public static function provideEscapeId() { - return [ - [ '+', '.2B' ], - [ '&', '.26' ], - [ '=', '.3D' ], - [ ':', ':' ], - [ ';', '.3B' ], - [ '@', '.40' ], - [ '$', '.24' ], - [ '-_.', '-_.' ], - [ '!', '.21' ], - [ '*', '.2A' ], - [ '/', '.2F' ], - [ '[]', '.5B.5D' ], - [ '<>', '.3C.3E' ], - [ '\'', '.27' ], - [ '§', '.C2.A7' ], - [ 'Test:A & B/Here', 'Test:A_.26_B.2FHere' ], - [ 'A&B&C&amp;D&amp;amp;E', 'A.26B.26amp.3BC.26amp.3Bamp.3BD.26amp.3Bamp.3Bamp.3BE' ], - ]; - } - - /** - * Test escapeIdReferenceList for consistency with escapeIdForAttribute - * - * @dataProvider provideEscapeIdReferenceList - * @covers Sanitizer::escapeIdReferenceList - */ - public function testEscapeIdReferenceList( $referenceList, $id1, $id2 ) { - $this->assertEquals( - Sanitizer::escapeIdReferenceList( $referenceList ), - Sanitizer::escapeIdForAttribute( $id1 ) - . ' ' - . Sanitizer::escapeIdForAttribute( $id2 ) - ); - } - - public static function provideEscapeIdReferenceList() { - /** [ , , ] */ - return [ - [ 'foo bar', 'foo', 'bar' ], - [ '#1 #2', '#1', '#2' ], - [ '+1 +2', '+1', '+2' ], - ]; + public function testAttributeWhitelist( $element, $attribs ) { + $this->hideDeprecated( 'Sanitizer::attributeWhitelist' ); + $this->hideDeprecated( 'Sanitizer::setupAttributeWhitelist' ); + $actual = Sanitizer::attributeWhitelist( $element ); + $this->assertArrayEquals( $attribs, $actual ); } /** - * @dataProvider provideIsReservedDataAttribute - * @covers Sanitizer::isReservedDataAttribute + * @dataProvider provideAttributeWhitelist + * @covers Sanitizer::attributeWhitelistInternal */ - public function testIsReservedDataAttribute( $attr, $expected ) { - $this->assertSame( $expected, Sanitizer::isReservedDataAttribute( $attr ) ); + public function testAttributeWhitelistInternal( $element, $attribs ) { + $sanitizer = TestingAccessWrapper::newFromClass( Sanitizer::class ); + $actual = $sanitizer->attributeWhitelistInternal( $element ); + $this->assertArrayEquals( $attribs, array_keys( $actual ) ); } - public static function provideIsReservedDataAttribute() { + public function provideAttributeWhitelist() { + /** [ , [ , , ...] ] */ return [ - [ 'foo', false ], - [ 'data', false ], - [ 'data-foo', false ], - [ 'data-mw', true ], - [ 'data-ooui', true ], - [ 'data-parsoid', true ], - [ 'data-mw-foo', true ], - [ 'data-ooui-foo', true ], - [ 'data-mwfoo', true ], // could be false but this is how it's implemented currently + [ 'math', [ 'class', 'style', 'id', 'title' ] ], + [ 'meta', [ 'itemprop', 'content' ] ], + [ 'link', [ 'itemprop', 'href', 'title' ] ], ]; } @@ -503,35 +245,6 @@ class SanitizerTest extends MediaWikiTestCase { ]; } - /** - * @dataProvider provideStripAllTags - * - * @covers Sanitizer::stripAllTags() - * @covers RemexStripTagHandler - * - * @param string $input - * @param string $expected - */ - public function testStripAllTags( $input, $expected ) { - $this->assertEquals( $expected, Sanitizer::stripAllTags( $input ) ); - } - - public function provideStripAllTags() { - return [ - [ '

    Foo

    ', 'Foo' ], - [ '

    Foo

    Bar

    ', 'Foo Bar' ], - [ "

    Foo

    \n

    Bar

    ", 'Foo Bar' ], - [ '

    Hello <strong> world café

    ', 'Hello world café' ], - [ - '

    quux\'>Bar Whee!

    ', - 'Bar Whee!' - ], - [ '123', '123' ], - [ '123', '123' ], - [ '12', '1 2' ], - ]; - } - /** * @expectedException InvalidArgumentException * @covers Sanitizer::escapeIdInternal() @@ -558,4 +271,29 @@ class SanitizerTest extends MediaWikiTestCase { $this->setMwGlobals( 'wgFragmentMode', [ 666 => 'html5' ] ); Sanitizer::escapeIdForLink( 'This should throw' ); } + + /** + * Test escapeIdReferenceList for consistency with escapeIdForAttribute + * + * @dataProvider provideEscapeIdReferenceList + * @covers Sanitizer::escapeIdReferenceList + */ + public function testEscapeIdReferenceList( $referenceList, $id1, $id2 ) { + $this->assertEquals( + Sanitizer::escapeIdReferenceList( $referenceList ), + Sanitizer::escapeIdForAttribute( $id1 ) + . ' ' + . Sanitizer::escapeIdForAttribute( $id2 ) + ); + } + + public static function provideEscapeIdReferenceList() { + /** [ , , ] */ + return [ + [ 'foo bar', 'foo', 'bar' ], + [ '#1 #2', '#1', '#2' ], + [ '+1 +2', '+1', '+2' ], + ]; + } + } diff --git a/tests/phpunit/includes/parser/TidyTest.php b/tests/phpunit/includes/parser/TidyTest.php deleted file mode 100644 index 898ef2d163..0000000000 --- a/tests/phpunit/includes/parser/TidyTest.php +++ /dev/null @@ -1,64 +0,0 @@ -markTestSkipped( 'Tidy not found' ); - } - } - - /** - * @dataProvider provideTestWrapping - */ - public function testTidyWrapping( $expected, $text, $msg = '' ) { - $text = MWTidy::tidy( $text ); - // We don't care about where Tidy wants to stick is

    s - $text = trim( preg_replace( '##', '', $text ) ); - // Windows, we love you! - $text = str_replace( "\r", '', $text ); - $this->assertEquals( $expected, $text, $msg ); - } - - public static function provideTestWrapping() { - $testMathML = <<<'MathML' - - - a - - - x - 2 - - + - b - - x - + - c - - -MathML; - return [ - [ - 'foo', - 'foo', - ' should survive tidy' - ], - [ - 'foo', - 'foo', - ' should survive tidy' - ], - [ 'foo', 'foo', ' should survive tidy' ], - [ "foo", 'foo', ' should survive tidy' ], - [ "foo", 'foo', ' should survive tidy' ], - [ $testMathML, $testMathML, ' should survive tidy' ], - ]; - } -} diff --git a/tests/phpunit/includes/password/PasswordFactoryTest.php b/tests/phpunit/includes/password/PasswordFactoryTest.php deleted file mode 100644 index a7b3557516..0000000000 --- a/tests/phpunit/includes/password/PasswordFactoryTest.php +++ /dev/null @@ -1,124 +0,0 @@ -assertEquals( [ '' ], array_keys( $pf->getTypes() ) ); - $this->assertEquals( '', $pf->getDefaultType() ); - - $pf = new PasswordFactory( [ - 'foo' => [ 'class' => 'FooPassword' ], - 'bar' => [ 'class' => 'BarPassword', 'baz' => 'boom' ], - ], 'foo' ); - $this->assertEquals( [ '', 'foo', 'bar' ], array_keys( $pf->getTypes() ) ); - $this->assertArraySubset( [ 'class' => 'BarPassword', 'baz' => 'boom' ], $pf->getTypes()['bar'] ); - $this->assertEquals( 'foo', $pf->getDefaultType() ); - } - - public function testRegister() { - $pf = new PasswordFactory; - $pf->register( 'foo', [ 'class' => InvalidPassword::class ] ); - $this->assertArrayHasKey( 'foo', $pf->getTypes() ); - } - - public function testSetDefaultType() { - $pf = new PasswordFactory; - $pf->register( '1', [ 'class' => InvalidPassword::class ] ); - $pf->register( '2', [ 'class' => InvalidPassword::class ] ); - $pf->setDefaultType( '1' ); - $this->assertSame( '1', $pf->getDefaultType() ); - $pf->setDefaultType( '2' ); - $this->assertSame( '2', $pf->getDefaultType() ); - } - - /** - * @expectedException Exception - */ - public function testSetDefaultTypeError() { - $pf = new PasswordFactory; - $pf->setDefaultType( 'bogus' ); - } - - public function testInit() { - $config = new HashConfig( [ - 'PasswordConfig' => [ - 'foo' => [ 'class' => InvalidPassword::class ], - ], - 'PasswordDefault' => 'foo' - ] ); - $pf = new PasswordFactory; - $pf->init( $config ); - $this->assertSame( 'foo', $pf->getDefaultType() ); - $this->assertArrayHasKey( 'foo', $pf->getTypes() ); - } - - public function testNewFromCiphertext() { - $pf = new PasswordFactory; - $pf->register( 'B', [ 'class' => MWSaltedPassword::class ] ); - $pw = $pf->newFromCiphertext( ':B:salt:d529e941509eb9e9b9cfaeae1fe7ca23' ); - $this->assertInstanceOf( MWSaltedPassword::class, $pw ); - } - - public function provideNewFromCiphertextErrors() { - return [ [ 'blah' ], [ ':blah:' ] ]; - } - - /** - * @dataProvider provideNewFromCiphertextErrors - * @expectedException PasswordError - */ - public function testNewFromCiphertextErrors( $hash ) { - $pf = new PasswordFactory; - $pf->register( 'B', [ 'class' => MWSaltedPassword::class ] ); - $pf->newFromCiphertext( $hash ); - } - - public function testNewFromType() { - $pf = new PasswordFactory; - $pf->register( 'B', [ 'class' => MWSaltedPassword::class ] ); - $pw = $pf->newFromType( 'B' ); - $this->assertInstanceOf( MWSaltedPassword::class, $pw ); - } - - /** - * @expectedException PasswordError - */ - public function testNewFromTypeError() { - $pf = new PasswordFactory; - $pf->register( 'B', [ 'class' => MWSaltedPassword::class ] ); - $pf->newFromType( 'bogus' ); - } - - public function testNewFromPlaintext() { - $pf = new PasswordFactory; - $pf->register( 'A', [ 'class' => MWOldPassword::class ] ); - $pf->register( 'B', [ 'class' => MWSaltedPassword::class ] ); - $pf->setDefaultType( 'A' ); - - $this->assertInstanceOf( InvalidPassword::class, $pf->newFromPlaintext( null ) ); - $this->assertInstanceOf( MWOldPassword::class, $pf->newFromPlaintext( 'password' ) ); - $this->assertInstanceOf( MWSaltedPassword::class, - $pf->newFromPlaintext( 'password', $pf->newFromType( 'B' ) ) ); - } - - public function testNeedsUpdate() { - $pf = new PasswordFactory; - $pf->register( 'A', [ 'class' => MWOldPassword::class ] ); - $pf->register( 'B', [ 'class' => MWSaltedPassword::class ] ); - $pf->setDefaultType( 'A' ); - - $this->assertFalse( $pf->needsUpdate( $pf->newFromType( 'A' ) ) ); - $this->assertTrue( $pf->needsUpdate( $pf->newFromType( 'B' ) ) ); - } - - public function testGenerateRandomPasswordString() { - $this->assertSame( 13, strlen( PasswordFactory::generateRandomPasswordString( 13 ) ) ); - } - - public function testNewInvalidPassword() { - $this->assertInstanceOf( InvalidPassword::class, PasswordFactory::newInvalidPassword() ); - } -} diff --git a/tests/phpunit/includes/password/PasswordTest.php b/tests/phpunit/includes/password/PasswordTest.php deleted file mode 100644 index 61a5147277..0000000000 --- a/tests/phpunit/includes/password/PasswordTest.php +++ /dev/null @@ -1,33 +0,0 @@ -newFromPlaintext( null ); - - $this->assertInstanceOf( InvalidPassword::class, $invalid ); - } -} diff --git a/tests/phpunit/includes/preferences/FiltersTest.php b/tests/phpunit/includes/preferences/FiltersTest.php deleted file mode 100644 index 60b01b880c..0000000000 --- a/tests/phpunit/includes/preferences/FiltersTest.php +++ /dev/null @@ -1,141 +0,0 @@ -filterFromForm( '0' ) ); - self::assertSame( 3, $filter->filterFromForm( '3' ) ); - self::assertSame( '123', $filter->filterForForm( '123' ) ); - } - - /** - * @covers MediaWiki\Preferences\TimezoneFilter::filterFromForm() - * @dataProvider provideTimezoneFilter - * - * @param string $input - * @param string $expected - */ - public function testTimezoneFilter( $input, $expected ) { - $filter = new TimezoneFilter(); - $result = $filter->filterFromForm( $input ); - self::assertEquals( $expected, $result ); - } - - public function provideTimezoneFilter() { - return [ - [ 'ZoneInfo', 'Offset|0' ], - [ 'ZoneInfo|bogus', 'Offset|0' ], - [ 'System', 'System' ], - [ '2:30', 'Offset|150' ], - ]; - } - - /** - * @covers MediaWiki\Preferences\MultiUsernameFilter::filterFromForm() - * @dataProvider provideMultiUsernameFilterFrom - * - * @param string $input - * @param string|null $expected - */ - public function testMultiUsernameFilterFrom( $input, $expected ) { - $filter = $this->makeMultiUsernameFilter(); - $result = $filter->filterFromForm( $input ); - self::assertSame( $expected, $result ); - } - - public function provideMultiUsernameFilterFrom() { - return [ - [ '', null ], - [ "\n\n\n", null ], - [ 'Foo', '1' ], - [ "\n\n\nFoo\nBar\n", "1\n2" ], - [ "Baz\nInvalid\nFoo", "3\n1" ], - [ "Invalid", null ], - [ "Invalid\n\n\nInvalid\n", null ], - ]; - } - - /** - * @covers MediaWiki\Preferences\MultiUsernameFilter::filterForForm() - * @dataProvider provideMultiUsernameFilterFor - * - * @param string $input - * @param string $expected - */ - public function testMultiUsernameFilterFor( $input, $expected ) { - $filter = $this->makeMultiUsernameFilter(); - $result = $filter->filterForForm( $input ); - self::assertSame( $expected, $result ); - } - - public function provideMultiUsernameFilterFor() { - return [ - [ '', '' ], - [ "\n", '' ], - [ '1', 'Foo' ], - [ "\n1\n\n2\377\n", "Foo\nBar" ], - [ "666\n667", '' ], - ]; - } - - private function makeMultiUsernameFilter() { - $userMapping = [ - 'Foo' => 1, - 'Bar' => 2, - 'Baz' => 3, - ]; - $flipped = array_flip( $userMapping ); - $idLookup = self::getMockBuilder( CentralIdLookup::class ) - ->disableOriginalConstructor() - ->setMethods( [ 'centralIdsFromNames', 'namesFromCentralIds' ] ) - ->getMockForAbstractClass(); - - $idLookup->method( 'centralIdsFromNames' ) - ->will( self::returnCallback( function ( $names ) use ( $userMapping ) { - $ids = []; - foreach ( $names as $name ) { - $ids[] = $userMapping[$name] ?? null; - } - return array_filter( $ids, 'is_numeric' ); - } ) ); - $idLookup->method( 'namesFromCentralIds' ) - ->will( self::returnCallback( function ( $ids ) use ( $flipped ) { - $names = []; - foreach ( $ids as $id ) { - $names[] = $flipped[$id] ?? null; - } - return array_filter( $names, 'is_string' ); - } ) ); - - return new MultiUsernameFilter( $idLookup ); - } -} diff --git a/tests/phpunit/includes/registration/ExtensionProcessorTest.php b/tests/phpunit/includes/registration/ExtensionProcessorTest.php deleted file mode 100644 index cdd5c63eff..0000000000 --- a/tests/phpunit/includes/registration/ExtensionProcessorTest.php +++ /dev/null @@ -1,829 +0,0 @@ -dir = __DIR__ . '/FooBar/extension.json'; - $this->dirname = dirname( $this->dir ); - } - - /** - * 'name' is absolutely required - * - * @var array - */ - public static $default = [ - 'name' => 'FooBar', - ]; - - public function testExtractInfo() { - // Test that attributes that begin with @ are ignored - $processor = new ExtensionProcessor(); - $processor->extractInfo( $this->dir, self::$default + [ - '@metadata' => [ 'foobarbaz' ], - 'AnAttribute' => [ 'omg' ], - 'AutoloadClasses' => [ 'FooBar' => 'includes/FooBar.php' ], - 'SpecialPages' => [ 'Foo' => 'SpecialFoo' ], - 'callback' => 'FooBar::onRegistration', - ], 1 ); - - $extracted = $processor->getExtractedInfo(); - $attributes = $extracted['attributes']; - $this->assertArrayHasKey( 'AnAttribute', $attributes ); - $this->assertArrayNotHasKey( '@metadata', $attributes ); - $this->assertArrayNotHasKey( 'AutoloadClasses', $attributes ); - $this->assertSame( - [ 'FooBar' => 'FooBar::onRegistration' ], - $extracted['callbacks'] - ); - $this->assertSame( - [ 'Foo' => 'SpecialFoo' ], - $extracted['globals']['wgSpecialPages'] - ); - } - - public function testExtractNamespaces() { - // Test that namespace IDs can be overwritten - if ( !defined( 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_X' ) ) { - define( 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_X', 123456 ); - } - - $processor = new ExtensionProcessor(); - $processor->extractInfo( $this->dir, self::$default + [ - 'namespaces' => [ - [ - 'id' => 332200, - 'constant' => 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_A', - 'name' => 'Test_A', - 'defaultcontentmodel' => 'TestModel', - 'gender' => [ - 'male' => 'Male test', - 'female' => 'Female test', - ], - 'subpages' => true, - 'content' => true, - 'protection' => 'userright', - ], - [ // Test_X will use ID 123456 not 334400 - 'id' => 334400, - 'constant' => 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_X', - 'name' => 'Test_X', - 'defaultcontentmodel' => 'TestModel' - ], - ] - ], 1 ); - - $extracted = $processor->getExtractedInfo(); - - $this->assertArrayHasKey( - 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_A', - $extracted['defines'] - ); - $this->assertArrayNotHasKey( - 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_X', - $extracted['defines'] - ); - - $this->assertSame( - $extracted['defines']['MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_A'], - 332200 - ); - - $this->assertArrayHasKey( 'ExtensionNamespaces', $extracted['attributes'] ); - $this->assertArrayHasKey( 123456, $extracted['attributes']['ExtensionNamespaces'] ); - $this->assertArrayHasKey( 332200, $extracted['attributes']['ExtensionNamespaces'] ); - $this->assertArrayNotHasKey( 334400, $extracted['attributes']['ExtensionNamespaces'] ); - - $this->assertSame( 'Test_X', $extracted['attributes']['ExtensionNamespaces'][123456] ); - $this->assertSame( 'Test_A', $extracted['attributes']['ExtensionNamespaces'][332200] ); - $this->assertSame( - [ 'male' => 'Male test', 'female' => 'Female test' ], - $extracted['globals']['wgExtraGenderNamespaces'][332200] - ); - // A has subpages, X does not - $this->assertTrue( $extracted['globals']['wgNamespacesWithSubpages'][332200] ); - $this->assertArrayNotHasKey( 123456, $extracted['globals']['wgNamespacesWithSubpages'] ); - } - - public static function provideRegisterHooks() { - $merge = [ ExtensionRegistry::MERGE_STRATEGY => 'array_merge_recursive' ]; - // Format: - // Current $wgHooks - // Content in extension.json - // Expected value of $wgHooks - return [ - // No hooks - [ - [], - self::$default, - $merge, - ], - // No current hooks, adding one for "FooBaz" in string format - [ - [], - [ 'Hooks' => [ 'FooBaz' => 'FooBazCallback' ] ] + self::$default, - [ 'FooBaz' => [ 'FooBazCallback' ] ] + $merge, - ], - // Hook for "FooBaz", adding another one - [ - [ 'FooBaz' => [ 'PriorCallback' ] ], - [ 'Hooks' => [ 'FooBaz' => 'FooBazCallback' ] ] + self::$default, - [ 'FooBaz' => [ 'PriorCallback', 'FooBazCallback' ] ] + $merge, - ], - // No current hooks, adding one for "FooBaz" in verbose array format - [ - [], - [ 'Hooks' => [ 'FooBaz' => [ 'FooBazCallback' ] ] ] + self::$default, - [ 'FooBaz' => [ 'FooBazCallback' ] ] + $merge, - ], - // Hook for "BarBaz", adding one for "FooBaz" - [ - [ 'BarBaz' => [ 'BarBazCallback' ] ], - [ 'Hooks' => [ 'FooBaz' => 'FooBazCallback' ] ] + self::$default, - [ - 'BarBaz' => [ 'BarBazCallback' ], - 'FooBaz' => [ 'FooBazCallback' ], - ] + $merge, - ], - // Callbacks for FooBaz wrapped in an array - [ - [], - [ 'Hooks' => [ 'FooBaz' => [ 'Callback1' ] ] ] + self::$default, - [ - 'FooBaz' => [ 'Callback1' ], - ] + $merge, - ], - // Multiple callbacks for FooBaz hook - [ - [], - [ 'Hooks' => [ 'FooBaz' => [ 'Callback1', 'Callback2' ] ] ] + self::$default, - [ - 'FooBaz' => [ 'Callback1', 'Callback2' ], - ] + $merge, - ], - ]; - } - - /** - * @dataProvider provideRegisterHooks - */ - public function testRegisterHooks( $pre, $info, $expected ) { - $processor = new MockExtensionProcessor( [ 'wgHooks' => $pre ] ); - $processor->extractInfo( $this->dir, $info, 1 ); - $extracted = $processor->getExtractedInfo(); - $this->assertEquals( $expected, $extracted['globals']['wgHooks'] ); - } - - public function testExtractConfig1() { - $processor = new ExtensionProcessor; - $info = [ - 'config' => [ - 'Bar' => 'somevalue', - 'Foo' => 10, - '@IGNORED' => 'yes', - ], - ] + self::$default; - $info2 = [ - 'config' => [ - '_prefix' => 'eg', - 'Bar' => 'somevalue' - ], - 'name' => 'FooBar2', - ]; - $processor->extractInfo( $this->dir, $info, 1 ); - $processor->extractInfo( $this->dir, $info2, 1 ); - $extracted = $processor->getExtractedInfo(); - $this->assertEquals( 'somevalue', $extracted['globals']['wgBar'] ); - $this->assertEquals( 10, $extracted['globals']['wgFoo'] ); - $this->assertArrayNotHasKey( 'wg@IGNORED', $extracted['globals'] ); - // Custom prefix: - $this->assertEquals( 'somevalue', $extracted['globals']['egBar'] ); - } - - public function testExtractConfig2() { - $processor = new ExtensionProcessor; - $info = [ - 'config' => [ - 'Bar' => [ 'value' => 'somevalue' ], - 'Foo' => [ 'value' => 10 ], - 'Path' => [ 'value' => 'foo.txt', 'path' => true ], - 'Namespaces' => [ - 'value' => [ - '10' => true, - '12' => false, - ], - 'merge_strategy' => 'array_plus', - ], - ], - ] + self::$default; - $info2 = [ - 'config' => [ - 'Bar' => [ 'value' => 'somevalue' ], - ], - 'config_prefix' => 'eg', - 'name' => 'FooBar2', - ]; - $processor->extractInfo( $this->dir, $info, 2 ); - $processor->extractInfo( $this->dir, $info2, 2 ); - $extracted = $processor->getExtractedInfo(); - $this->assertEquals( 'somevalue', $extracted['globals']['wgBar'] ); - $this->assertEquals( 10, $extracted['globals']['wgFoo'] ); - $this->assertEquals( "{$this->dirname}/foo.txt", $extracted['globals']['wgPath'] ); - // Custom prefix: - $this->assertEquals( 'somevalue', $extracted['globals']['egBar'] ); - $this->assertSame( - [ 10 => true, 12 => false, ExtensionRegistry::MERGE_STRATEGY => 'array_plus' ], - $extracted['globals']['wgNamespaces'] - ); - } - - /** - * @expectedException RuntimeException - */ - public function testDuplicateConfigKey1() { - $processor = new ExtensionProcessor; - $info = [ - 'config' => [ - 'Bar' => '', - ] - ] + self::$default; - $info2 = [ - 'config' => [ - 'Bar' => 'g', - ], - 'name' => 'FooBar2', - ]; - $processor->extractInfo( $this->dir, $info, 1 ); - $processor->extractInfo( $this->dir, $info2, 1 ); - } - - /** - * @expectedException RuntimeException - */ - public function testDuplicateConfigKey2() { - $processor = new ExtensionProcessor; - $info = [ - 'config' => [ - 'Bar' => [ 'value' => 'somevalue' ], - ] - ] + self::$default; - $info2 = [ - 'config' => [ - 'Bar' => [ 'value' => 'somevalue' ], - ], - 'name' => 'FooBar2', - ]; - $processor->extractInfo( $this->dir, $info, 2 ); - $processor->extractInfo( $this->dir, $info2, 2 ); - } - - public static function provideExtractExtensionMessagesFiles() { - $dir = __DIR__ . '/FooBar/'; - return [ - [ - [ 'ExtensionMessagesFiles' => [ 'FooBarAlias' => 'FooBar.alias.php' ] ], - [ 'wgExtensionMessagesFiles' => [ 'FooBarAlias' => $dir . 'FooBar.alias.php' ] ] - ], - [ - [ - 'ExtensionMessagesFiles' => [ - 'FooBarAlias' => 'FooBar.alias.php', - 'FooBarMagic' => 'FooBar.magic.i18n.php', - ], - ], - [ - 'wgExtensionMessagesFiles' => [ - 'FooBarAlias' => $dir . 'FooBar.alias.php', - 'FooBarMagic' => $dir . 'FooBar.magic.i18n.php', - ], - ], - ], - ]; - } - - /** - * @dataProvider provideExtractExtensionMessagesFiles - */ - public function testExtractExtensionMessagesFiles( $input, $expected ) { - $processor = new ExtensionProcessor(); - $processor->extractInfo( $this->dir, $input + self::$default, 1 ); - $out = $processor->getExtractedInfo(); - foreach ( $expected as $key => $value ) { - $this->assertEquals( $value, $out['globals'][$key] ); - } - } - - public static function provideExtractMessagesDirs() { - $dir = __DIR__ . '/FooBar/'; - return [ - [ - [ 'MessagesDirs' => [ 'VisualEditor' => 'i18n' ] ], - [ 'wgMessagesDirs' => [ 'VisualEditor' => [ $dir . 'i18n' ] ] ] - ], - [ - [ 'MessagesDirs' => [ 'VisualEditor' => [ 'i18n', 'foobar' ] ] ], - [ 'wgMessagesDirs' => [ 'VisualEditor' => [ $dir . 'i18n', $dir . 'foobar' ] ] ] - ], - ]; - } - - /** - * @dataProvider provideExtractMessagesDirs - */ - public function testExtractMessagesDirs( $input, $expected ) { - $processor = new ExtensionProcessor(); - $processor->extractInfo( $this->dir, $input + self::$default, 1 ); - $out = $processor->getExtractedInfo(); - foreach ( $expected as $key => $value ) { - $this->assertEquals( $value, $out['globals'][$key] ); - } - } - - public function testExtractCredits() { - $processor = new ExtensionProcessor(); - $processor->extractInfo( $this->dir, self::$default, 1 ); - $this->setExpectedException( Exception::class ); - $processor->extractInfo( $this->dir, self::$default, 1 ); - } - - /** - * @dataProvider provideExtractResourceLoaderModules - */ - public function testExtractResourceLoaderModules( - $input, - array $expectedGlobals, - array $expectedAttribs = [] - ) { - $processor = new ExtensionProcessor(); - $processor->extractInfo( $this->dir, $input + self::$default, 1 ); - $out = $processor->getExtractedInfo(); - foreach ( $expectedGlobals as $key => $value ) { - $this->assertEquals( $value, $out['globals'][$key] ); - } - foreach ( $expectedAttribs as $key => $value ) { - $this->assertEquals( $value, $out['attributes'][$key] ); - } - } - - public static function provideExtractResourceLoaderModules() { - $dir = __DIR__ . '/FooBar'; - return [ - // Generic module with localBasePath/remoteExtPath specified - [ - // Input - [ - 'ResourceModules' => [ - 'test.foo' => [ - 'styles' => 'foobar.js', - 'localBasePath' => '', - 'remoteExtPath' => 'FooBar', - ], - ], - ], - // Expected - [ - 'wgResourceModules' => [ - 'test.foo' => [ - 'styles' => 'foobar.js', - 'localBasePath' => $dir, - 'remoteExtPath' => 'FooBar', - ], - ], - ], - ], - // ResourceFileModulePaths specified: - [ - // Input - [ - 'ResourceFileModulePaths' => [ - 'localBasePath' => 'modules', - 'remoteExtPath' => 'FooBar/modules', - ], - 'ResourceModules' => [ - // No paths - 'test.foo' => [ - 'styles' => 'foo.js', - ], - // Different paths set - 'test.bar' => [ - 'styles' => 'bar.js', - 'localBasePath' => 'subdir', - 'remoteExtPath' => 'FooBar/subdir', - ], - // Custom class with no paths set - 'test.class' => [ - 'class' => 'FooBarModule', - 'extra' => 'argument', - ], - // Custom class with a localBasePath - 'test.class.with.path' => [ - 'class' => 'FooBarPathModule', - 'extra' => 'argument', - 'localBasePath' => '', - ] - ], - ], - // Expected - [ - 'wgResourceModules' => [ - 'test.foo' => [ - 'styles' => 'foo.js', - 'localBasePath' => "$dir/modules", - 'remoteExtPath' => 'FooBar/modules', - ], - 'test.bar' => [ - 'styles' => 'bar.js', - 'localBasePath' => "$dir/subdir", - 'remoteExtPath' => 'FooBar/subdir', - ], - 'test.class' => [ - 'class' => 'FooBarModule', - 'extra' => 'argument', - 'localBasePath' => "$dir/modules", - 'remoteExtPath' => 'FooBar/modules', - ], - 'test.class.with.path' => [ - 'class' => 'FooBarPathModule', - 'extra' => 'argument', - 'localBasePath' => $dir, - 'remoteExtPath' => 'FooBar/modules', - ] - ], - ], - ], - // ResourceModuleSkinStyles with file module paths - [ - // Input - [ - 'ResourceFileModulePaths' => [ - 'localBasePath' => '', - 'remoteSkinPath' => 'FooBar', - ], - 'ResourceModuleSkinStyles' => [ - 'foobar' => [ - 'test.foo' => 'foo.css', - ] - ], - ], - // Expected - [ - 'wgResourceModuleSkinStyles' => [ - 'foobar' => [ - 'test.foo' => 'foo.css', - 'localBasePath' => $dir, - 'remoteSkinPath' => 'FooBar', - ], - ], - ], - ], - // ResourceModuleSkinStyles with file module paths and an override - [ - // Input - [ - 'ResourceFileModulePaths' => [ - 'localBasePath' => '', - 'remoteSkinPath' => 'FooBar', - ], - 'ResourceModuleSkinStyles' => [ - 'foobar' => [ - 'test.foo' => 'foo.css', - 'remoteSkinPath' => 'BarFoo' - ], - ], - ], - // Expected - [ - 'wgResourceModuleSkinStyles' => [ - 'foobar' => [ - 'test.foo' => 'foo.css', - 'localBasePath' => $dir, - 'remoteSkinPath' => 'BarFoo', - ], - ], - ], - ], - 'QUnit test module' => [ - // Input - [ - 'QUnitTestModule' => [ - 'localBasePath' => '', - 'remoteExtPath' => 'Foo', - 'scripts' => 'bar.js', - ], - ], - // Expected - [], - [ - 'QUnitTestModules' => [ - 'test.FooBar' => [ - 'localBasePath' => $dir, - 'remoteExtPath' => 'Foo', - 'scripts' => 'bar.js', - ], - ], - ], - ], - ]; - } - - public static function provideSetToGlobal() { - return [ - [ - [ 'wgAPIModules', 'wgAvailableRights' ], - [], - [ - 'APIModules' => [ 'foobar' => 'ApiFooBar' ], - 'AvailableRights' => [ 'foobar', 'unfoobar' ], - ], - [ - 'wgAPIModules' => [ 'foobar' => 'ApiFooBar' ], - 'wgAvailableRights' => [ 'foobar', 'unfoobar' ], - ], - ], - [ - [ 'wgAPIModules', 'wgAvailableRights' ], - [ - 'wgAPIModules' => [ 'barbaz' => 'ApiBarBaz' ], - 'wgAvailableRights' => [ 'barbaz' ] - ], - [ - 'APIModules' => [ 'foobar' => 'ApiFooBar' ], - 'AvailableRights' => [ 'foobar', 'unfoobar' ], - ], - [ - 'wgAPIModules' => [ 'barbaz' => 'ApiBarBaz', 'foobar' => 'ApiFooBar' ], - 'wgAvailableRights' => [ 'barbaz', 'foobar', 'unfoobar' ], - ], - ], - [ - [ 'wgGroupPermissions' ], - [ - 'wgGroupPermissions' => [ - 'sysop' => [ 'delete' ] - ], - ], - [ - 'GroupPermissions' => [ - 'sysop' => [ 'undelete' ], - 'user' => [ 'edit' ] - ], - ], - [ - 'wgGroupPermissions' => [ - 'sysop' => [ 'delete', 'undelete' ], - 'user' => [ 'edit' ] - ], - ] - ] - ]; - } - - /** - * Attributes under manifest_version 2 - */ - public function testExtractAttributes() { - $processor = new ExtensionProcessor(); - // Load FooBar extension - $processor->extractInfo( $this->dir, [ 'name' => 'FooBar' ], 2 ); - $processor->extractInfo( - $this->dir, - [ - 'name' => 'Baz', - 'attributes' => [ - // Loaded - 'FooBar' => [ - 'Plugins' => [ - 'ext.baz.foobar', - ], - ], - // Not loaded - 'FizzBuzz' => [ - 'MorePlugins' => [ - 'ext.baz.fizzbuzz', - ], - ], - ], - ], - 2 - ); - - $info = $processor->getExtractedInfo(); - $this->assertArrayHasKey( 'FooBarPlugins', $info['attributes'] ); - $this->assertSame( [ 'ext.baz.foobar' ], $info['attributes']['FooBarPlugins'] ); - $this->assertArrayNotHasKey( 'FizzBuzzMorePlugins', $info['attributes'] ); - } - - /** - * Attributes under manifest_version 1 - */ - public function testAttributes1() { - $processor = new ExtensionProcessor(); - $processor->extractInfo( - $this->dir, - [ - 'name' => 'FooBar', - 'FooBarPlugins' => [ - 'ext.baz.foobar', - ], - 'FizzBuzzMorePlugins' => [ - 'ext.baz.fizzbuzz', - ], - ], - 1 - ); - $processor->extractInfo( - $this->dir, - [ - 'name' => 'FooBar2', - 'FizzBuzzMorePlugins' => [ - 'ext.bar.fizzbuzz', - ] - ], - 1 - ); - - $info = $processor->getExtractedInfo(); - $this->assertArrayHasKey( 'FooBarPlugins', $info['attributes'] ); - $this->assertSame( [ 'ext.baz.foobar' ], $info['attributes']['FooBarPlugins'] ); - $this->assertArrayHasKey( 'FizzBuzzMorePlugins', $info['attributes'] ); - $this->assertSame( - [ 'ext.baz.fizzbuzz', 'ext.bar.fizzbuzz' ], - $info['attributes']['FizzBuzzMorePlugins'] - ); - } - - public function testAttributes1_notarray() { - $processor = new ExtensionProcessor(); - $this->setExpectedException( - InvalidArgumentException::class, - "The value for 'FooBarPlugins' should be an array (from {$this->dir})" - ); - $processor->extractInfo( - $this->dir, - [ - 'FooBarPlugins' => 'ext.baz.foobar', - ] + self::$default, - 1 - ); - } - - public function testExtractPathBasedGlobal() { - $processor = new ExtensionProcessor(); - $processor->extractInfo( - $this->dir, - [ - 'ParserTestFiles' => [ - 'tests/parserTests.txt', - 'tests/extraParserTests.txt', - ], - 'ServiceWiringFiles' => [ - 'includes/ServiceWiring.php' - ], - ] + self::$default, - 1 - ); - $globals = $processor->getExtractedInfo()['globals']; - $this->assertArrayHasKey( 'wgParserTestFiles', $globals ); - $this->assertSame( [ - "{$this->dirname}/tests/parserTests.txt", - "{$this->dirname}/tests/extraParserTests.txt" - ], $globals['wgParserTestFiles'] ); - $this->assertArrayHasKey( 'wgServiceWiringFiles', $globals ); - $this->assertSame( [ - "{$this->dirname}/includes/ServiceWiring.php" - ], $globals['wgServiceWiringFiles'] ); - } - - public function testGetRequirements() { - $info = self::$default + [ - 'requires' => [ - 'MediaWiki' => '>= 1.25.0', - 'platform' => [ - 'php' => '>= 5.5.9' - ], - 'extensions' => [ - 'Bar' => '*' - ] - ] - ]; - $processor = new ExtensionProcessor(); - $this->assertSame( - $info['requires'], - $processor->getRequirements( $info, false ) - ); - $this->assertSame( - [], - $processor->getRequirements( [], false ) - ); - } - - public function testGetDevRequirements() { - $info = self::$default + [ - 'dev-requires' => [ - 'MediaWiki' => '>= 1.31.0', - 'platform' => [ - 'ext-foo' => '*', - ], - 'skins' => [ - 'Baz' => '*', - ], - 'extensions' => [ - 'Biz' => '*', - ], - ], - ]; - $processor = new ExtensionProcessor(); - $this->assertSame( - $info['dev-requires'], - $processor->getRequirements( $info, true ) - ); - // Set some standard requirements, so we can test merging - $info['requires'] = [ - 'MediaWiki' => '>= 1.25.0', - 'platform' => [ - 'php' => '>= 5.5.9' - ], - 'extensions' => [ - 'Bar' => '*' - ] - ]; - $this->assertSame( - [ - 'MediaWiki' => '>= 1.25.0 >= 1.31.0', - 'platform' => [ - 'php' => '>= 5.5.9', - 'ext-foo' => '*', - ], - 'extensions' => [ - 'Bar' => '*', - 'Biz' => '*', - ], - 'skins' => [ - 'Baz' => '*', - ], - ], - $processor->getRequirements( $info, true ) - ); - - // If there's no dev-requires, it just returns requires - unset( $info['dev-requires'] ); - $this->assertSame( - $info['requires'], - $processor->getRequirements( $info, true ) - ); - } - - public function testGetExtraAutoloaderPaths() { - $processor = new ExtensionProcessor(); - $this->assertSame( - [ "{$this->dirname}/vendor/autoload.php" ], - $processor->getExtraAutoloaderPaths( $this->dirname, [ - 'load_composer_autoloader' => true, - ] ) - ); - } - - /** - * Verify that extension.schema.json is in sync with ExtensionProcessor - * - * @coversNothing - */ - public function testGlobalSettingsDocumentedInSchema() { - global $IP; - $globalSettings = TestingAccessWrapper::newFromClass( - ExtensionProcessor::class )->globalSettings; - - $version = ExtensionRegistry::MANIFEST_VERSION; - $schema = FormatJson::decode( - file_get_contents( "$IP/docs/extension.schema.v$version.json" ), - true - ); - $missing = []; - foreach ( $globalSettings as $global ) { - if ( !isset( $schema['properties'][$global] ) ) { - $missing[] = $global; - } - } - - $this->assertEquals( [], $missing, - "The following global settings are not documented in docs/extension.schema.json" ); - } -} - -/** - * Allow overriding the default value of $this->globals - * so we can test merging - */ -class MockExtensionProcessor extends ExtensionProcessor { - public function __construct( $globals = [] ) { - $this->globals = $globals + $this->globals; - } -} diff --git a/tests/phpunit/includes/registration/VersionCheckerTest.php b/tests/phpunit/includes/registration/VersionCheckerTest.php deleted file mode 100644 index e824e3f02c..0000000000 --- a/tests/phpunit/includes/registration/VersionCheckerTest.php +++ /dev/null @@ -1,479 +0,0 @@ -assertEquals( $expected, !(bool)$checker->checkArray( [ - 'FakeExtension' => [ - 'MediaWiki' => $constraint, - ], - ] ) ); - } - - public static function provideMediaWikiCheck() { - return [ - // [ $wgVersion, constraint, expected ] - [ '1.25alpha', '>= 1.26', false ], - [ '1.25.0', '>= 1.26', false ], - [ '1.26alpha', '>= 1.26', true ], - [ '1.26alpha', '>= 1.26.0', true ], - [ '1.26alpha', '>= 1.26.0-stable', false ], - [ '1.26.0', '>= 1.26.0-stable', true ], - [ '1.26.1', '>= 1.26.0-stable', true ], - [ '1.27.1', '>= 1.26.0-stable', true ], - [ '1.26alpha', '>= 1.26.1', false ], - [ '1.26alpha', '>= 1.26alpha', true ], - [ '1.26alpha', '>= 1.25', true ], - [ '1.26.0-alpha.14', '>= 1.26.0-alpha.15', false ], - [ '1.26.0-alpha.14', '>= 1.26.0-alpha.10', true ], - [ '1.26.1', '>= 1.26.2, <=1.26.0', false ], - [ '1.26.1', '^1.26.2', false ], - // Accept anything for un-parsable version strings - [ '1.26mwf14', '== 1.25alpha', true ], - [ 'totallyinvalid', '== 1.0', true ], - ]; - } - - /** - * @dataProvider providePhpValidCheck - */ - public function testPhpValidCheck( $phpVersion, $constraint, $expected ) { - $checker = new VersionChecker( '1.0.0', $phpVersion, [] ); - $this->assertEquals( $expected, !(bool)$checker->checkArray( [ - 'FakeExtension' => [ - 'platform' => [ - 'php' => $constraint, - ], - ], - ] ) ); - } - - public static function providePhpValidCheck() { - return [ - // [ phpVersion, constraint, expected ] - [ '7.0.23', '>= 7.0.0', true ], - [ '7.0.23', '^7.1.0', false ], - [ '7.0.23', '7.0.23', true ], - ]; - } - - /** - * @expectedException UnexpectedValueException - */ - public function testPhpInvalidConstraint() { - $checker = new VersionChecker( '1.0.0', '7.0.0', [] ); - $checker->checkArray( [ - 'FakeExtension' => [ - 'platform' => [ - 'php' => 'totallyinvalid', - ], - ], - ] ); - } - - /** - * @dataProvider providePhpInvalidVersion - * @expectedException UnexpectedValueException - */ - public function testPhpInvalidVersion( $phpVersion ) { - $checker = new VersionChecker( '1.0.0', $phpVersion, [] ); - } - - public static function providePhpInvalidVersion() { - return [ - // [ phpVersion ] - [ '7.abc' ], - [ '5.a.x' ], - ]; - } - - /** - * @dataProvider provideType - */ - public function testType( $given, $expected ) { - $checker = new VersionChecker( - '1.0.0', - '7.0.0', - [ 'phpLoadedExtension' ], - [ - 'presentAbility' => true, - 'presentAbilityWithMessage' => true, - 'missingAbility' => false, - 'missingAbilityWithMessage' => false, - ], - [ - 'presentAbilityWithMessage' => 'Present.', - 'missingAbilityWithMessage' => 'Missing.', - ] - ); - $checker->setLoadedExtensionsAndSkins( [ - 'FakeDependency' => [ - 'version' => '1.0.0', - ], - 'NoVersionGiven' => [], - ] ); - $this->assertEquals( $expected, $checker->checkArray( [ - 'FakeExtension' => $given, - ] ) ); - } - - public static function provideType() { - return [ - // valid type - [ - [ - 'extensions' => [ - 'FakeDependency' => '1.0.0', - ], - ], - [], - ], - [ - [ - 'MediaWiki' => '1.0.0', - ], - [], - ], - [ - [ - 'extensions' => [ - 'NoVersionGiven' => '*', - ], - ], - [], - ], - [ - [ - 'extensions' => [ - 'NoVersionGiven' => '1.0', - ], - ], - [ - [ - 'incompatible' => 'FakeExtension', - 'type' => 'incompatible-extensions', - 'msg' => 'NoVersionGiven does not expose its version, but FakeExtension requires: 1.0.', - ], - ], - ], - [ - [ - 'extensions' => [ - 'Missing' => '*', - ], - ], - [ - [ - 'missing' => 'Missing', - 'type' => 'missing-extensions', - 'msg' => 'FakeExtension requires Missing to be installed.', - ], - ], - ], - [ - [ - 'extensions' => [ - 'FakeDependency' => '2.0.0', - ], - ], - [ - [ - 'incompatible' => 'FakeExtension', - 'type' => 'incompatible-extensions', - // phpcs:ignore Generic.Files.LineLength.TooLong - 'msg' => 'FakeExtension is not compatible with the current installed version of FakeDependency (1.0.0), it requires: 2.0.0.', - ], - ], - ], - [ - [ - 'skins' => [ - 'FakeSkin' => '*', - ], - ], - [ - [ - 'missing' => 'FakeSkin', - 'type' => 'missing-skins', - 'msg' => 'FakeExtension requires FakeSkin to be installed.', - ], - ], - ], - [ - [ - 'platform' => [ - 'ext-phpLoadedExtension' => '*', - ], - ], - [], - ], - [ - [ - 'platform' => [ - 'ext-phpMissingExtension' => '*', - ], - ], - [ - [ - 'missing' => 'phpMissingExtension', - 'type' => 'missing-phpExtension', - // phpcs:ignore Generic.Files.LineLength.TooLong - 'msg' => 'FakeExtension requires phpMissingExtension PHP extension to be installed.', - ], - ], - ], - [ - [ - 'platform' => [ - 'ability-presentAbility' => true, - ], - ], - [], - ], - [ - [ - 'platform' => [ - 'ability-presentAbilityWithMessage' => true, - ], - ], - [], - ], - [ - [ - 'platform' => [ - 'ability-presentAbility' => false, - ], - ], - [], - ], - [ - [ - 'platform' => [ - 'ability-presentAbilityWithMessage' => false, - ], - ], - [], - ], - [ - [ - 'platform' => [ - 'ability-missingAbility' => true, - ], - ], - [ - [ - 'missing' => 'missingAbility', - 'type' => 'missing-ability', - 'msg' => 'FakeExtension requires "missingAbility" ability', - ], - ], - ], - [ - [ - 'platform' => [ - 'ability-missingAbilityWithMessage' => true, - ], - ], - [ - [ - 'missing' => 'missingAbilityWithMessage', - 'type' => 'missing-ability', - // phpcs:ignore Generic.Files.LineLength.TooLong - 'msg' => 'FakeExtension requires "missingAbilityWithMessage" ability: Missing.', - ], - ], - ], - [ - [ - 'platform' => [ - 'ability-missingAbility' => false, - ], - ], - [], - ], - [ - [ - 'platform' => [ - 'ability-missingAbilityWithMessage' => false, - ], - ], - [], - ], - ]; - } - - /** - * Check, if a non-parsable version constraint does not throw an exception or - * returns any error message. - */ - public function testInvalidConstraint() { - $checker = new VersionChecker( '1.0.0', '7.0.0', [] ); - $checker->setLoadedExtensionsAndSkins( [ - 'FakeDependency' => [ - 'version' => 'not really valid', - ], - ] ); - $this->assertEquals( [ - [ - 'type' => 'invalid-version', - 'msg' => "FakeDependency does not have a valid version string.", - ], - ], $checker->checkArray( [ - 'FakeExtension' => [ - 'extensions' => [ - 'FakeDependency' => '1.24.3', - ], - ], - ] ) ); - - $checker = new VersionChecker( '1.0.0', '7.0.0', [] ); - $checker->setLoadedExtensionsAndSkins( [ - 'FakeDependency' => [ - 'version' => '1.24.3', - ], - ] ); - - $this->setExpectedException( UnexpectedValueException::class ); - $checker->checkArray( [ - 'FakeExtension' => [ - 'FakeDependency' => 'not really valid', - ], - ] ); - } - - public function provideInvalidDependency() { - return [ - [ - [ - 'FakeExtension' => [ - 'platform' => [ - 'undefinedPlatformDependency' => '*', - ], - ], - ], - 'undefinedPlatformDependency', - ], - [ - [ - 'FakeExtension' => [ - 'platform' => [ - 'phpLoadedExtension' => '*', - ], - ], - ], - 'phpLoadedExtension', - ], - [ - [ - 'FakeExtension' => [ - 'platform' => [ - 'ability-invalidAbility' => true, - ], - ], - ], - 'ability-invalidAbility', - ], - [ - [ - 'FakeExtension' => [ - 'platform' => [ - 'presentAbility' => true, - ], - ], - ], - 'presentAbility', - ], - [ - [ - 'FakeExtension' => [ - 'undefinedDependencyType' => '*', - ], - ], - 'undefinedDependencyType', - ], - // T197478 - [ - [ - 'FakeExtension' => [ - 'skin' => [ - 'FakeSkin' => '*', - ], - ], - ], - 'skin', - ], - ]; - } - - /** - * @dataProvider provideInvalidDependency - */ - public function testInvalidDependency( $depencency, $type ) { - $checker = new VersionChecker( - '1.0.0', - '7.0.0', - [ 'phpLoadedExtension' ], - [ - 'presentAbility' => true, - 'missingAbility' => false, - ] - ); - $this->setExpectedException( - UnexpectedValueException::class, - "Dependency type $type unknown in FakeExtension" - ); - $checker->checkArray( $depencency ); - } - - public function testInvalidPhpExtensionConstraint() { - $checker = new VersionChecker( '1.0.0', '7.0.0', [ 'phpLoadedExtension' ] ); - $this->setExpectedException( - UnexpectedValueException::class, - 'Version constraints for PHP extensions are not supported in FakeExtension' - ); - $checker->checkArray( [ - 'FakeExtension' => [ - 'platform' => [ - 'ext-phpLoadedExtension' => '1.0.0', - ], - ], - ] ); - } - - /** - * @dataProvider provideInvalidAbilityType - */ - public function testInvalidAbilityType( $value ) { - $checker = new VersionChecker( '1.0.0', '7.0.0', [], [ 'presentAbility' => true ] ); - $this->setExpectedException( - UnexpectedValueException::class, - 'Only booleans are allowed to to indicate the presence of abilities in FakeExtension' - ); - $checker->checkArray( [ - 'FakeExtension' => [ - 'platform' => [ - 'ability-presentAbility' => $value, - ], - ], - ] ); - } - - public function provideInvalidAbilityType() { - return [ - [ null ], - [ 1 ], - [ '1' ], - ]; - } - -} diff --git a/tests/phpunit/includes/resourceloader/MessageBlobStoreTest.php b/tests/phpunit/includes/resourceloader/MessageBlobStoreTest.php index e094d92b9d..628ddb1ba1 100644 --- a/tests/phpunit/includes/resourceloader/MessageBlobStoreTest.php +++ b/tests/phpunit/includes/resourceloader/MessageBlobStoreTest.php @@ -11,6 +11,8 @@ class MessageBlobStoreTest extends PHPUnit\Framework\TestCase { use MediaWikiCoversValidator; use PHPUnit4And6Compat; + const NAME = 'test.blobstore'; + protected function setUp() { parent::setUp(); // MediaWiki's test wrapper sets $wgMainWANCache to CACHE_NONE. @@ -24,12 +26,15 @@ class MessageBlobStoreTest extends PHPUnit\Framework\TestCase { } public function testBlobCreation() { - $module = $this->makeModule( [ 'mainpage' ] ); $rl = new EmptyResourceLoader(); - $rl->register( $module->getName(), $module ); + $rl->register( self::NAME, [ + 'factory' => function () { + return $this->makeModule( [ 'mainpage' ] ); + } + ] ); $blobStore = $this->makeBlobStore( null, $rl ); - $blob = $blobStore->getBlob( $module, 'en' ); + $blob = $blobStore->getBlob( $rl->getModule( self::NAME ), 'en' ); $this->assertEquals( '{"mainpage":"Main Page"}', $blob, 'Generated blob' ); } @@ -37,7 +42,6 @@ class MessageBlobStoreTest extends PHPUnit\Framework\TestCase { public function testBlobCreation_empty() { $module = $this->makeModule( [] ); $rl = new EmptyResourceLoader(); - $rl->register( $module->getName(), $module ); $blobStore = $this->makeBlobStore( null, $rl ); $blob = $blobStore->getBlob( $module, 'en' ); @@ -48,7 +52,6 @@ class MessageBlobStoreTest extends PHPUnit\Framework\TestCase { public function testBlobCreation_unknownMessage() { $module = $this->makeModule( [ 'i-dont-exist', 'mainpage', 'i-dont-exist2' ] ); $rl = new EmptyResourceLoader(); - $rl->register( $module->getName(), $module ); $blobStore = $this->makeBlobStore( null, $rl ); // Generating a blob should continue without errors, @@ -58,9 +61,15 @@ class MessageBlobStoreTest extends PHPUnit\Framework\TestCase { } public function testMessageCachingAndPurging() { - $module = $this->makeModule( [ 'example' ] ); $rl = new EmptyResourceLoader(); - $rl->register( $module->getName(), $module ); + // Register it so that MessageBlobStore::updateMessage can + // discover it from the registry as a module that uses this message. + $rl->register( self::NAME, [ + 'factory' => function () { + return $this->makeModule( [ 'example' ] ); + } + ] ); + $module = $rl->getModule( self::NAME ); $blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl ); // Advance this new WANObjectCache instance to a normal state, @@ -105,7 +114,6 @@ class MessageBlobStoreTest extends PHPUnit\Framework\TestCase { public function testPurgeEverything() { $module = $this->makeModule( [ 'example' ] ); $rl = new EmptyResourceLoader(); - $rl->register( $module->getName(), $module ); $blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl ); // Advance this new WANObjectCache instance to a normal state. $blobStore->getBlob( $module, 'en' ); @@ -139,7 +147,6 @@ class MessageBlobStoreTest extends PHPUnit\Framework\TestCase { // Arrange version 1 of a module $module = $this->makeModule( [ 'foo' ] ); $rl = new EmptyResourceLoader(); - $rl->register( $module->getName(), $module ); $blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl ); $blobStore->expects( $this->once() ) ->method( 'fetchMessage' ) @@ -158,7 +165,6 @@ class MessageBlobStoreTest extends PHPUnit\Framework\TestCase { // We do not receive purges for this because no messages were changed. $module = $this->makeModule( [ 'foo', 'bar' ] ); $rl = new EmptyResourceLoader(); - $rl->register( $module->getName(), $module ); $blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl ); $blobStore->expects( $this->exactly( 2 ) ) ->method( 'fetchMessage' ) @@ -191,7 +197,7 @@ class MessageBlobStoreTest extends PHPUnit\Framework\TestCase { private function makeModule( array $messages ) { $module = new ResourceLoaderTestModule( [ 'messages' => $messages ] ); - $module->setName( 'test.blobstore' ); + $module->setName( self::NAME ); return $module; } } diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php index 206160c7cf..e1ee3248cc 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php @@ -116,9 +116,9 @@ Deprecation message.' ] . '' . "\n" - . '' . "\n" + . '' . "\n" . '' . "\n" - . ''; + . ''; // phpcs:enable $expected = self::expandVariables( $expected ); @@ -136,7 +136,7 @@ Deprecation message.' ] // phpcs:disable Generic.Files.LineLength $expected = '' . "\n" - . ''; + . ''; // phpcs:enable $this->assertSame( $expected, (string)$client->getHeadHtml() ); @@ -153,7 +153,7 @@ Deprecation message.' ] // phpcs:disable Generic.Files.LineLength $expected = '' . "\n" - . ''; + . ''; // phpcs:enable $this->assertSame( $expected, (string)$client->getHeadHtml() ); @@ -170,7 +170,7 @@ Deprecation message.' ] // phpcs:disable Generic.Files.LineLength $expected = '' . "\n" - . ''; + . ''; // phpcs:enable $this->assertSame( $expected, (string)$client->getHeadHtml() ); @@ -224,54 +224,54 @@ Deprecation message.' ] ], [ 'context' => [], - // Eg. startup module - 'modules' => [ 'test.scripts.raw' ], + 'modules' => [ 'test.scripts' ], 'only' => ResourceLoaderModule::TYPE_SCRIPTS, - 'extra' => [], - 'output' => '', + // Eg. startup module + 'extra' => [ 'raw' => '1' ], + 'output' => '', ], [ 'context' => [], - 'modules' => [ 'test.scripts.raw' ], + 'modules' => [ 'test.scripts' ], 'only' => ResourceLoaderModule::TYPE_SCRIPTS, - 'extra' => [ 'sync' => '1' ], - 'output' => '', + 'extra' => [ 'raw' => '1', 'sync' => '1' ], + 'output' => '', ], [ 'context' => [], 'modules' => [ 'test.scripts.user' ], 'only' => ResourceLoaderModule::TYPE_SCRIPTS, 'extra' => [], - 'output' => '', + 'output' => '', ], [ 'context' => [], 'modules' => [ 'test.user' ], 'only' => ResourceLoaderModule::TYPE_COMBINED, 'extra' => [], - 'output' => '', + 'output' => '', ], [ 'context' => [ 'debug' => 'true' ], 'modules' => [ 'test.styles.pure', 'test.styles.mixed' ], 'only' => ResourceLoaderModule::TYPE_STYLES, 'extra' => [], - 'output' => '' . "\n" - . '', + 'output' => '' . "\n" + . '', ], [ 'context' => [ 'debug' => 'false' ], 'modules' => [ 'test.styles.pure', 'test.styles.mixed' ], 'only' => ResourceLoaderModule::TYPE_STYLES, 'extra' => [], - 'output' => '', + 'output' => '', ], [ 'context' => [], 'modules' => [ 'test.styles.noscript' ], 'only' => ResourceLoaderModule::TYPE_STYLES, 'extra' => [], - 'output' => '', + 'output' => '', ], [ 'context' => [], @@ -299,7 +299,7 @@ Deprecation message.' ] 'modules' => [ 'test', 'test.shouldembed' ], 'only' => ResourceLoaderModule::TYPE_COMBINED, 'extra' => [], - 'output' => '', + 'output' => '', ], [ 'context' => [], @@ -307,7 +307,7 @@ Deprecation message.' ] 'only' => ResourceLoaderModule::TYPE_STYLES, 'extra' => [], 'output' => - '' . "\n" + '' . "\n" . '' ], [ @@ -316,9 +316,9 @@ Deprecation message.' ] 'only' => ResourceLoaderModule::TYPE_STYLES, 'extra' => [], 'output' => - '' . "\n" + '' . "\n" . '' . "\n" - . '' + . '' ], ]; // phpcs:enable @@ -374,7 +374,7 @@ Deprecation message.' ] } private static function makeModule( array $options = [] ) { - return new ResourceLoaderTestModule( $options ); + return $options + [ 'class' => ResourceLoaderTestModule::class ]; } private static function makeSampleModules() { @@ -418,7 +418,6 @@ Deprecation message.' ] 'test.scripts' => [], 'test.scripts.user' => [ 'group' => 'user' ], 'test.scripts.user.empty' => [ 'group' => 'user', 'isKnownEmpty' => true ], - 'test.scripts.raw' => [ 'isRaw' => true ], 'test.scripts.shouldembed' => [ 'shouldEmbed' => true ], 'test.ordering.a' => [ 'shouldEmbed' => false ], diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderContextTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderContextTest.php index 7f4d9a8137..c3d5ec1fed 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderContextTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderContextTest.php @@ -47,8 +47,10 @@ class ResourceLoaderContextTest extends PHPUnit\Framework\TestCase { public function testAccessors() { $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [] ) ); + $this->assertInstanceOf( ResourceLoader::class, $ctx->getResourceLoader() ); + $this->assertInstanceOf( Config::class, $ctx->getConfig() ); $this->assertInstanceOf( WebRequest::class, $ctx->getRequest() ); - $this->assertInstanceOf( \Psr\Log\LoggerInterface::class, $ctx->getLogger() ); + $this->assertInstanceOf( Psr\Log\LoggerInterface::class, $ctx->getLogger() ); } public function testTypicalRequest() { @@ -76,6 +78,38 @@ class ResourceLoaderContextTest extends PHPUnit\Framework\TestCase { $this->assertEquals( 'zh|fallback|||styles|||||', $ctx->getHash() ); } + public static function provideDirection() { + yield 'LTR language' => [ + [ 'lang' => 'en' ], + 'ltr', + ]; + yield 'RTL language' => [ + [ 'lang' => 'he' ], + 'rtl', + ]; + yield 'explicit LTR' => [ + [ 'lang' => 'he', 'dir' => 'ltr' ], + 'ltr', + ]; + yield 'explicit RTL' => [ + [ 'lang' => 'en', 'dir' => 'rtl' ], + 'rtl', + ]; + // Not supported, but tested to cover the case and detect change + yield 'invalid dir' => [ + [ 'lang' => 'he', 'dir' => 'xyz' ], + 'rtl', + ]; + } + + /** + * @dataProvider provideDirection + */ + public function testDirection( array $params, $expected ) { + $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( $params ) ); + $this->assertEquals( $expected, $ctx->getDirection() ); + } + public function testShouldInclude() { $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [] ) ); $this->assertTrue( $ctx->shouldIncludeScripts(), 'Scripts in combined' ); diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderFileModuleTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderFileModuleTest.php index fbef12ea36..a9e7fcfd32 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderFileModuleTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderFileModuleTest.php @@ -1,7 +1,6 @@ setService( 'SkinFactory', $skinFactory ); + + // This test is not expected to query any database + MediaWiki\MediaWikiServices::disableStorageBackend(); } private static function getModules() { $base = [ - 'localBasePath' => realpath( __DIR__ ), + 'localBasePath' => __DIR__, ]; return [ @@ -229,12 +231,12 @@ class ResourceLoaderFileModuleTest extends ResourceLoaderTestCase { */ public function testMixedCssAnnotations() { $basePath = __DIR__ . '/../../data/css'; - $testModule = new ResourceLoaderFileModule( [ + $testModule = new ResourceLoaderFileTestModule( [ 'localBasePath' => $basePath, 'styles' => [ 'test.css' ], ] ); $testModule->setName( 'testing' ); - $expectedModule = new ResourceLoaderFileModule( [ + $expectedModule = new ResourceLoaderFileTestModule( [ 'localBasePath' => $basePath, 'styles' => [ 'expected.css' ], ] ); @@ -263,6 +265,47 @@ class ResourceLoaderFileModuleTest extends ResourceLoaderTestCase { ); } + /** + * Test reading files from elsewhere than localBasePath using ResourceLoaderFilePath. + * + * This mimics modules modified by skins using 'ResourceModuleSkinStyles' and 'OOUIThemePaths' + * skin attributes. + * + * @covers ResourceLoaderFilePath::getLocalBasePath + * @covers ResourceLoaderFilePath::getRemoteBasePath + */ + public function testResourceLoaderFilePath() { + $basePath = __DIR__ . '/../../data/blahblah'; + $filePath = __DIR__ . '/../../data/rlfilepath'; + $testModule = new ResourceLoaderFileModule( [ + 'localBasePath' => $basePath, + 'remoteBasePath' => 'blahblah', + 'styles' => new ResourceLoaderFilePath( 'style.css', $filePath, 'rlfilepath' ), + 'skinStyles' => [ + 'vector' => new ResourceLoaderFilePath( 'skinStyle.css', $filePath, 'rlfilepath' ), + ], + 'scripts' => new ResourceLoaderFilePath( 'script.js', $filePath, 'rlfilepath' ), + 'templates' => new ResourceLoaderFilePath( 'template.html', $filePath, 'rlfilepath' ), + ] ); + $expectedModule = new ResourceLoaderFileModule( [ + 'localBasePath' => $filePath, + 'remoteBasePath' => 'rlfilepath', + 'styles' => 'style.css', + 'skinStyles' => [ + 'vector' => 'skinStyle.css', + ], + 'scripts' => 'script.js', + 'templates' => 'template.html', + ] ); + + $context = $this->getResourceLoaderContext(); + $this->assertEquals( + $expectedModule->getModuleContent( $context ), + $testModule->getModuleContent( $context ), + "Using ResourceLoaderFilePath works correctly" + ); + } + public static function providerGetTemplates() { $modules = self::getModules(); @@ -319,7 +362,7 @@ class ResourceLoaderFileModuleTest extends ResourceLoaderTestCase { */ public function testBomConcatenation() { $basePath = __DIR__ . '/../../data/css'; - $testModule = new ResourceLoaderFileModule( [ + $testModule = new ResourceLoaderFileTestModule( [ 'localBasePath' => $basePath, 'styles' => [ 'bom.css' ], ] ); @@ -347,7 +390,6 @@ class ResourceLoaderFileModuleTest extends ResourceLoaderTestCase { $module = new ResourceLoaderFileTestModule( [ 'localBasePath' => $basePath, 'styles' => [ 'styles.less' ], - ], [ 'lessVars' => [ 'foo' => '2px', 'Foo' => '#eeeeee' ] ] ); $module->setName( 'test.less' ); @@ -355,27 +397,110 @@ class ResourceLoaderFileModuleTest extends ResourceLoaderTestCase { $this->assertStringEqualsFile( $basePath . '/styles.css', $styles['all'] ); } + public function provideGetVersionHash() { + $a = []; + $b = [ + 'lessVars' => [ 'key' => 'value' ], + ]; + yield 'with and without Less variables' => [ $a, $b, false ]; + + $a = [ + 'lessVars' => [ 'key' => 'value1' ], + ]; + $b = [ + 'lessVars' => [ 'key' => 'value2' ], + ]; + yield 'different Less variables' => [ $a, $b, false ]; + + $x = [ + 'lessVars' => [ 'key' => 'value' ], + ]; + yield 'identical Less variables' => [ $x, $x, true ]; + + $a = [ + 'packageFiles' => [ [ 'name' => 'data.json', 'callback' => function () { + return [ 'aaa' ]; + } ] ] + ]; + $b = [ + 'packageFiles' => [ [ 'name' => 'data.json', 'callback' => function () { + return [ 'bbb' ]; + } ] ] + ]; + yield 'packageFiles with different callback' => [ $a, $b, false ]; + + $a = [ + 'packageFiles' => [ [ 'name' => 'aaa.json', 'callback' => function () { + return [ 'x' ]; + } ] ] + ]; + $b = [ + 'packageFiles' => [ [ 'name' => 'bbb.json', 'callback' => function () { + return [ 'x' ]; + } ] ] + ]; + yield 'packageFiles with different file name and a callback' => [ $a, $b, false ]; + + $a = [ + 'packageFiles' => [ [ 'name' => 'data.json', 'versionCallback' => function () { + return [ 'A-version' ]; + }, 'callback' => function () { + throw new Exception( 'Unexpected computation' ); + } ] ] + ]; + $b = [ + 'packageFiles' => [ [ 'name' => 'data.json', 'versionCallback' => function () { + return [ 'B-version' ]; + }, 'callback' => function () { + throw new Exception( 'Unexpected computation' ); + } ] ] + ]; + yield 'packageFiles with different versionCallback' => [ $a, $b, false ]; + + $a = [ + 'packageFiles' => [ [ 'name' => 'aaa.json', + 'versionCallback' => function () { + return [ 'X-version' ]; + }, + 'callback' => function () { + throw new Exception( 'Unexpected computation' ); + } + ] ] + ]; + $b = [ + 'packageFiles' => [ [ 'name' => 'bbb.json', + 'versionCallback' => function () { + return [ 'X-version' ]; + }, + 'callback' => function () { + throw new Exception( 'Unexpected computation' ); + } + ] ] + ]; + yield 'packageFiles with different file name and a versionCallback' => [ $a, $b, false ]; + } + /** + * @dataProvider provideGetVersionHash * @covers ResourceLoaderFileModule::getDefinitionSummary * @covers ResourceLoaderFileModule::getFileHashes */ - public function testGetVersionHash() { + public function testGetVersionHash( $a, $b, $isEqual ) { $context = $this->getResourceLoaderContext(); - // Less variables - $module = new ResourceLoaderFileTestModule(); - $version = $module->getVersionHash( $context ); - $module = new ResourceLoaderFileTestModule( [], [ - 'lessVars' => [ 'key' => 'value' ], - ] ); - $this->assertNotEquals( - $version, - $module->getVersionHash( $context ), - 'Using less variables is significant' + $moduleA = new ResourceLoaderFileTestModule( $a ); + $versionA = $moduleA->getVersionHash( $context ); + $moduleB = new ResourceLoaderFileTestModule( $b ); + $versionB = $moduleB->getVersionHash( $context ); + + $this->assertSame( + $isEqual, + ( $versionA === $versionB ), + 'Whether versions hashes are equal' ); } - public function providerGetScriptPackageFiles() { + public function provideGetScriptPackageFiles() { $basePath = __DIR__ . '/../../data/resourceloader'; $base = [ 'localBasePath' => $basePath ]; $commentScript = file_get_contents( "$basePath/script-comment.js" ); @@ -451,7 +576,7 @@ class ResourceLoaderFileModuleTest extends ResourceLoaderTestCase { 'main' => 'init.js' ] ], - [ + 'package file with callback' => [ $base + [ 'packageFiles' => [ [ 'name' => 'foo.json', 'content' => [ 'Hello' => 'world' ] ], @@ -498,6 +623,34 @@ class ResourceLoaderFileModuleTest extends ResourceLoaderTestCase { 'lang' => 'fy' ] ], + 'package file with callback and versionCallback' => [ + $base + [ + 'packageFiles' => [ + [ 'name' => 'bar.js', 'content' => "console.log('Hello');" ], + [ 'name' => 'data.json', 'versionCallback' => function ( $context ) { + return $context->getLanguage(); + }, 'callback' => function ( $context ) { + return [ 'langCode' => $context->getLanguage() ]; + } ], + ] + ], + [ + 'files' => [ + 'bar.js' => [ + 'type' => 'script', + 'content' => "console.log('Hello');", + ], + 'data.json' => [ + 'type' => 'data', + 'content' => [ 'langCode' => 'fy' ] + ], + ], + 'main' => 'bar.js' + ], + [ + 'lang' => 'fy' + ] + ], [ $base + [ 'packageFiles' => [ @@ -506,7 +659,7 @@ class ResourceLoaderFileModuleTest extends ResourceLoaderTestCase { ], false ], - [ + 'package file with invalid callback' => [ $base + [ 'packageFiles' => [ [ 'name' => 'foo.json', 'callback' => 'functionThatDoesNotExist142857' ] @@ -559,7 +712,7 @@ class ResourceLoaderFileModuleTest extends ResourceLoaderTestCase { } /** - * @dataProvider providerGetScriptPackageFiles + * @dataProvider provideGetScriptPackageFiles * @covers ResourceLoaderFileModule::getScript * @covers ResourceLoaderFileModule::getPackageFiles * @covers ResourceLoaderFileModule::expandPackageFiles diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderImageModuleTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderImageModuleTest.php index 3f5704d6f4..dad9f1ed4f 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderImageModuleTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderImageModuleTest.php @@ -144,6 +144,55 @@ class ResourceLoaderImageModuleTest extends ResourceLoaderTestCase { ]; } + /** + * Test reading files from elsewhere than localBasePath using ResourceLoaderFilePath. + * + * This mimics modules modified by skins using 'ResourceModuleSkinStyles' and 'OOUIThemePaths' + * skin attributes. + * + * @covers ResourceLoaderFilePath::getLocalBasePath + * @covers ResourceLoaderFilePath::getRemoteBasePath + */ + public function testResourceLoaderFilePath() { + $basePath = __DIR__ . '/../../data/blahblah'; + $filePath = __DIR__ . '/../../data/rlfilepath'; + $testModule = new ResourceLoaderImageModule( [ + 'localBasePath' => $basePath, + 'remoteBasePath' => 'blahblah', + 'prefix' => 'foo', + 'images' => [ + 'eye' => new ResourceLoaderFilePath( 'eye.svg', $filePath, 'rlfilepath' ), + 'flag' => [ + 'file' => [ + 'ltr' => new ResourceLoaderFilePath( 'flag-ltr.svg', $filePath, 'rlfilepath' ), + 'rtl' => new ResourceLoaderFilePath( 'flag-rtl.svg', $filePath, 'rlfilepath' ), + ], + ], + ], + ] ); + $expectedModule = new ResourceLoaderImageModule( [ + 'localBasePath' => $filePath, + 'remoteBasePath' => 'rlfilepath', + 'prefix' => 'foo', + 'images' => [ + 'eye' => 'eye.svg', + 'flag' => [ + 'file' => [ + 'ltr' => 'flag-ltr.svg', + 'rtl' => 'flag-rtl.svg', + ], + ], + ], + ] ); + + $context = $this->getResourceLoaderContext(); + $this->assertEquals( + $expectedModule->getModuleContent( $context ), + $testModule->getModuleContent( $context ), + "Using ResourceLoaderFilePath works correctly" + ); + } + /** * @dataProvider providerGetModules * @covers ResourceLoaderImageModule::getStyles diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderImageTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderImageTest.php index b0512faac6..c3fc55acc1 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderImageTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderImageTest.php @@ -62,7 +62,6 @@ class ResourceLoaderImageTest extends ResourceLoaderTestCase { 'he' => 'rtl', 'ar' => 'rtl', ]; - static $contexts = []; $image = $this->getTestImage( $imageName ); $context = $this->getResourceLoaderContext( [ diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderModuleTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderModuleTest.php index 0c707d5537..60b40736d9 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderModuleTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderModuleTest.php @@ -56,7 +56,7 @@ class ResourceLoaderModuleTest extends ResourceLoaderTestCase { ); // Subclass - $module = new ResourceLoaderFileModuleTestModule( $baseParams ); + $module = new ResourceLoaderFileModuleTestingSubclass( $baseParams ); $this->assertNotEquals( $version, json_encode( $module->getVersionHash( $context ) ), @@ -86,6 +86,7 @@ class ResourceLoaderModuleTest extends ResourceLoaderTestCase { $context = $this->getResourceLoaderContext(); $module = new ResourceLoaderTestModule( [ + 'mayValidateScript' => true, 'script' => "var a = 'this is';\n {\ninvalid" ] ); $this->assertEquals( diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderStartUpModuleTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderStartUpModuleTest.php index b5dd008b3f..213eed2aea 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderStartUpModuleTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderStartUpModuleTest.php @@ -14,40 +14,52 @@ class ResourceLoaderStartUpModuleTest extends ResourceLoaderTestCase { 'msg' => 'Empty registry', 'modules' => [], 'out' => ' -mw.loader.addSource( { +mw.loader.addSource({ "local": "/w/load.php" -} ); -mw.loader.register( [] );' +}); +mw.loader.register([]);' ] ], [ [ 'msg' => 'Basic registry', 'modules' => [ - 'test.blank' => new ResourceLoaderTestModule(), + 'test.blank' => [ 'class' => ResourceLoaderTestModule::class ], ], 'out' => ' -mw.loader.addSource( { +mw.loader.addSource({ "local": "/w/load.php" -} ); -mw.loader.register( [ +}); +mw.loader.register([ [ "test.blank", "{blankVer}" ] -] );', +]);', ] ], [ [ 'msg' => 'Optimise the dependency tree (basic case)', 'modules' => [ - 'a' => new ResourceLoaderTestModule( [ 'dependencies' => [ 'b', 'c', 'd' ] ] ), - 'b' => new ResourceLoaderTestModule( [ 'dependencies' => [ 'c' ] ] ), - 'c' => new ResourceLoaderTestModule( [ 'dependencies' => [] ] ), - 'd' => new ResourceLoaderTestModule( [ 'dependencies' => [] ] ), + 'a' => [ + 'class' => ResourceLoaderTestModule::class, + 'dependencies' => [ 'b', 'c', 'd' ], + ], + 'b' => [ + 'class' => ResourceLoaderTestModule::class, + 'dependencies' => [ 'c' ], + ], + 'c' => [ + 'class' => ResourceLoaderTestModule::class, + 'dependencies' => [], + ], + 'd' => [ + 'class' => ResourceLoaderTestModule::class, + 'dependencies' => [], + ], ], 'out' => ' -mw.loader.addSource( { +mw.loader.addSource({ "local": "/w/load.php" -} ); -mw.loader.register( [ +}); +mw.loader.register([ [ "a", "{blankVer}", @@ -71,20 +83,29 @@ mw.loader.register( [ "d", "{blankVer}" ] -] );', +]);', ] ], [ [ 'msg' => 'Optimise the dependency tree (tolerate unknown deps)', 'modules' => [ - 'a' => new ResourceLoaderTestModule( [ 'dependencies' => [ 'b', 'c', 'x' ] ] ), - 'b' => new ResourceLoaderTestModule( [ 'dependencies' => [ 'c', 'x' ] ] ), - 'c' => new ResourceLoaderTestModule( [ 'dependencies' => [] ] ), + 'a' => [ + 'class' => ResourceLoaderTestModule::class, + 'dependencies' => [ 'b', 'c', 'x' ] + ], + 'b' => [ + 'class' => ResourceLoaderTestModule::class, + 'dependencies' => [ 'c', 'x' ] + ], + 'c' => [ + 'class' => ResourceLoaderTestModule::class, + 'dependencies' => [] + ], ], 'out' => ' -mw.loader.addSource( { +mw.loader.addSource({ "local": "/w/load.php" -} ); -mw.loader.register( [ +}); +mw.loader.register([ [ "a", "{blankVer}", @@ -105,102 +126,198 @@ mw.loader.register( [ "c", "{blankVer}" ] -] );', +]);', ] ], [ [ - 'msg' => 'Omit raw modules from registry', + // Regression test for T223402. + 'msg' => 'Optimise the dependency tree (indirect circular dependency)', 'modules' => [ - 'test.raw' => new ResourceLoaderTestModule( [ 'isRaw' => true ] ), - 'test.blank' => new ResourceLoaderTestModule(), + 'top' => [ + 'class' => ResourceLoaderTestModule::class, + 'dependencies' => [ 'middle1', 'util' ], + ], + 'middle1' => [ + 'class' => ResourceLoaderTestModule::class, + 'dependencies' => [ 'middle2', 'util' ], + ], + 'middle2' => [ + 'class' => ResourceLoaderTestModule::class, + 'dependencies' => [ 'bottom' ], + ], + 'bottom' => [ + 'class' => ResourceLoaderTestModule::class, + 'dependencies' => [ 'top' ], + ], + 'util' => [ + 'class' => ResourceLoaderTestModule::class, + 'dependencies' => [], + ], ], 'out' => ' -mw.loader.addSource( { +mw.loader.addSource({ "local": "/w/load.php" -} ); -mw.loader.register( [ +}); +mw.loader.register([ [ - "test.blank", + "top", + "{blankVer}", + [ + 1, + 4 + ] + ], + [ + "middle1", + "{blankVer}", + [ + 2, + 4 + ] + ], + [ + "middle2", + "{blankVer}", + [ + 3 + ] + ], + [ + "bottom", + "{blankVer}", + [ + 0 + ] + ], + [ + "util", "{blankVer}" ] -] );', +]);', + ] ], + [ [ + // Regression test for T223402. + 'msg' => 'Optimise the dependency tree (direct circular dependency)', + 'modules' => [ + 'top' => [ + 'class' => ResourceLoaderTestModule::class, + 'dependencies' => [ 'util', 'top' ], + ], + 'util' => [ + 'class' => ResourceLoaderTestModule::class, + 'dependencies' => [], + ], + ], + 'out' => ' +mw.loader.addSource({ + "local": "/w/load.php" +}); +mw.loader.register([ + [ + "top", + "{blankVer}", + [ + 1, + 0 + ] + ], + [ + "util", + "{blankVer}" + ] +]);', ] ], [ [ 'msg' => 'Version falls back gracefully if getVersionHash throws', 'modules' => [ - 'test.fail' => ( - ( $mock = $this->getMockBuilder( ResourceLoaderTestModule::class ) - ->setMethods( [ 'getVersionHash' ] )->getMock() ) - && $mock->method( 'getVersionHash' )->will( - $this->throwException( new Exception ) - ) - ) ? $mock : $mock + 'test.fail' => [ + 'factory' => function () { + $mock = $this->getMockBuilder( ResourceLoaderTestModule::class ) + ->setMethods( [ 'getVersionHash' ] )->getMock(); + $mock->method( 'getVersionHash' )->will( + $this->throwException( new Exception ) + ); + return $mock; + } + ] ], 'out' => ' -mw.loader.addSource( { +mw.loader.addSource({ "local": "/w/load.php" -} ); -mw.loader.register( [ +}); +mw.loader.register([ [ "test.fail", "" ] -] ); -mw.loader.state( { +]); +mw.loader.state({ "test.fail": "error" -} );', +});', ] ], [ [ 'msg' => 'Use version from getVersionHash', 'modules' => [ - 'test.version' => ( - ( $mock = $this->getMockBuilder( ResourceLoaderTestModule::class ) - ->setMethods( [ 'getVersionHash' ] )->getMock() ) - && $mock->method( 'getVersionHash' )->willReturn( '1234567' ) - ) ? $mock : $mock + 'test.version' => [ + 'factory' => function () { + $mock = $this->getMockBuilder( ResourceLoaderTestModule::class ) + ->setMethods( [ 'getVersionHash' ] )->getMock(); + $mock->method( 'getVersionHash' )->willReturn( '1234567' ); + return $mock; + } + ] ], 'out' => ' -mw.loader.addSource( { +mw.loader.addSource({ "local": "/w/load.php" -} ); -mw.loader.register( [ +}); +mw.loader.register([ [ "test.version", "1234567" ] -] );', +]);', ] ], [ [ 'msg' => 'Re-hash version from getVersionHash if too long', 'modules' => [ - 'test.version' => ( - ( $mock = $this->getMockBuilder( ResourceLoaderTestModule::class ) - ->setMethods( [ 'getVersionHash' ] )->getMock() ) - && $mock->method( 'getVersionHash' )->willReturn( '12345678' ) - ) ? $mock : $mock + 'test.version' => [ + 'factory' => function () { + $mock = $this->getMockBuilder( ResourceLoaderTestModule::class ) + ->setMethods( [ 'getVersionHash' ] )->getMock(); + $mock->method( 'getVersionHash' )->willReturn( '12345678' ); + return $mock; + } + ], ], 'out' => ' -mw.loader.addSource( { +mw.loader.addSource({ "local": "/w/load.php" -} ); -mw.loader.register( [ +}); +mw.loader.register([ [ "test.version", "016es8l" ] -] );', +]);', ] ], [ [ 'msg' => 'Group signature', 'modules' => [ - 'test.blank' => new ResourceLoaderTestModule(), - 'test.group.foo' => new ResourceLoaderTestModule( [ 'group' => 'x-foo' ] ), - 'test.group.bar' => new ResourceLoaderTestModule( [ 'group' => 'x-bar' ] ), + 'test.blank' => [ 'class' => ResourceLoaderTestModule::class ], + 'test.group.foo' => [ + 'class' => ResourceLoaderTestModule::class, + 'group' => 'x-foo', + ], + 'test.group.bar' => [ + 'class' => ResourceLoaderTestModule::class, + 'group' => 'x-bar', + ], ], 'out' => ' -mw.loader.addSource( { +mw.loader.addSource({ "local": "/w/load.php" -} ); -mw.loader.register( [ +}); +mw.loader.register([ [ "test.blank", "{blankVer}" @@ -217,45 +334,51 @@ mw.loader.register( [ [], "x-bar" ] -] );' +]);' ] ], [ [ 'msg' => 'Different target (non-test should not be registered)', 'modules' => [ - 'test.blank' => new ResourceLoaderTestModule(), - 'test.target.foo' => new ResourceLoaderTestModule( [ 'targets' => [ 'x-foo' ] ] ), + 'test.blank' => [ 'class' => ResourceLoaderTestModule::class ], + 'test.target.foo' => [ + 'class' => ResourceLoaderTestModule::class, + 'targets' => [ 'x-foo' ], + ], ], 'out' => ' -mw.loader.addSource( { +mw.loader.addSource({ "local": "/w/load.php" -} ); -mw.loader.register( [ +}); +mw.loader.register([ [ "test.blank", "{blankVer}" ] -] );' +]);' ] ], [ [ 'msg' => 'Safemode disabled (default; register all modules)', 'modules' => [ // Default origin: ORIGIN_CORE_SITEWIDE - 'test.blank' => new ResourceLoaderTestModule(), - 'test.core-generated' => new ResourceLoaderTestModule( [ + 'test.blank' => [ 'class' => ResourceLoaderTestModule::class ], + 'test.core-generated' => [ + 'class' => ResourceLoaderTestModule::class, 'origin' => ResourceLoaderModule::ORIGIN_CORE_INDIVIDUAL - ] ), - 'test.sitewide' => new ResourceLoaderTestModule( [ + ], + 'test.sitewide' => [ + 'class' => ResourceLoaderTestModule::class, 'origin' => ResourceLoaderModule::ORIGIN_USER_SITEWIDE - ] ), - 'test.user' => new ResourceLoaderTestModule( [ + ], + 'test.user' => [ + 'class' => ResourceLoaderTestModule::class, 'origin' => ResourceLoaderModule::ORIGIN_USER_INDIVIDUAL - ] ), + ], ], 'out' => ' -mw.loader.addSource( { +mw.loader.addSource({ "local": "/w/load.php" -} ); -mw.loader.register( [ +}); +mw.loader.register([ [ "test.blank", "{blankVer}" @@ -272,29 +395,32 @@ mw.loader.register( [ "test.user", "{blankVer}" ] -] );' +]);' ] ], [ [ 'msg' => 'Safemode enabled (filter modules with user/site origin)', 'extraQuery' => [ 'safemode' => '1' ], 'modules' => [ // Default origin: ORIGIN_CORE_SITEWIDE - 'test.blank' => new ResourceLoaderTestModule(), - 'test.core-generated' => new ResourceLoaderTestModule( [ + 'test.blank' => [ 'class' => ResourceLoaderTestModule::class ], + 'test.core-generated' => [ + 'class' => ResourceLoaderTestModule::class, 'origin' => ResourceLoaderModule::ORIGIN_CORE_INDIVIDUAL - ] ), - 'test.sitewide' => new ResourceLoaderTestModule( [ + ], + 'test.sitewide' => [ + 'class' => ResourceLoaderTestModule::class, 'origin' => ResourceLoaderModule::ORIGIN_USER_SITEWIDE - ] ), - 'test.user' => new ResourceLoaderTestModule( [ + ], + 'test.user' => [ + 'class' => ResourceLoaderTestModule::class, 'origin' => ResourceLoaderModule::ORIGIN_USER_INDIVIDUAL - ] ), + ], ], 'out' => ' -mw.loader.addSource( { +mw.loader.addSource({ "local": "/w/load.php" -} ); -mw.loader.register( [ +}); +mw.loader.register([ [ "test.blank", "{blankVer}" @@ -303,7 +429,7 @@ mw.loader.register( [ "test.core-generated", "{blankVer}" ] -] );' +]);' ] ], [ [ 'msg' => 'Foreign source', @@ -314,14 +440,17 @@ mw.loader.register( [ ], ], 'modules' => [ - 'test.blank' => new ResourceLoaderTestModule( [ 'source' => 'example' ] ), + 'test.blank' => [ + 'class' => ResourceLoaderTestModule::class, + 'source' => 'example' + ], ], 'out' => ' -mw.loader.addSource( { +mw.loader.addSource({ "local": "/w/load.php", "example": "http://example.org/w/load.php" -} ); -mw.loader.register( [ +}); +mw.loader.register([ [ "test.blank", "{blankVer}", @@ -329,36 +458,39 @@ mw.loader.register( [ null, "example" ] -] );' +]);' ] ], [ [ 'msg' => 'Conditional dependency function', 'modules' => [ - 'test.x.core' => new ResourceLoaderTestModule(), - 'test.x.polyfill' => new ResourceLoaderTestModule( [ + 'test.x.core' => [ 'class' => ResourceLoaderTestModule::class ], + 'test.x.polyfill' => [ + 'class' => ResourceLoaderTestModule::class, 'skipFunction' => 'return true;' - ] ), - 'test.y.polyfill' => new ResourceLoaderTestModule( [ + ], + 'test.y.polyfill' => [ + 'class' => ResourceLoaderTestModule::class, 'skipFunction' => 'return !!(' . ' window.JSON &&' . ' JSON.parse &&' . ' JSON.stringify' . ');' - ] ), - 'test.z.foo' => new ResourceLoaderTestModule( [ + ], + 'test.z.foo' => [ + 'class' => ResourceLoaderTestModule::class, 'dependencies' => [ 'test.x.core', 'test.x.polyfill', 'test.y.polyfill', ], - ] ), + ], ], 'out' => ' -mw.loader.addSource( { +mw.loader.addSource({ "local": "/w/load.php" -} ); -mw.loader.register( [ +}); +mw.loader.register([ [ "test.x.core", "{blankVer}" @@ -388,7 +520,7 @@ mw.loader.register( [ 2 ] ] -] );', +]);', ] ], [ [ // This may seem like an edge case, but a plain MediaWiki core install @@ -403,59 +535,69 @@ mw.loader.register( [ ], ], 'modules' => [ - 'test.blank' => new ResourceLoaderTestModule(), - 'test.x.core' => new ResourceLoaderTestModule(), - 'test.x.util' => new ResourceLoaderTestModule( [ + 'test.blank' => [ 'class' => ResourceLoaderTestModule::class ], + 'test.x.core' => [ 'class' => ResourceLoaderTestModule::class ], + 'test.x.util' => [ + 'class' => ResourceLoaderTestModule::class, 'dependencies' => [ 'test.x.core', ], - ] ), - 'test.x.foo' => new ResourceLoaderTestModule( [ + ], + 'test.x.foo' => [ + 'class' => ResourceLoaderTestModule::class, 'dependencies' => [ 'test.x.core', ], - ] ), - 'test.x.bar' => new ResourceLoaderTestModule( [ + ], + 'test.x.bar' => [ + 'class' => ResourceLoaderTestModule::class, 'dependencies' => [ 'test.x.core', 'test.x.util', ], - ] ), - 'test.x.quux' => new ResourceLoaderTestModule( [ + ], + 'test.x.quux' => [ + 'class' => ResourceLoaderTestModule::class, 'dependencies' => [ 'test.x.foo', 'test.x.bar', 'test.x.util', 'test.x.unknown', ], - ] ), - 'test.group.foo.1' => new ResourceLoaderTestModule( [ + ], + 'test.group.foo.1' => [ + 'class' => ResourceLoaderTestModule::class, 'group' => 'x-foo', - ] ), - 'test.group.foo.2' => new ResourceLoaderTestModule( [ + ], + 'test.group.foo.2' => [ + 'class' => ResourceLoaderTestModule::class, 'group' => 'x-foo', - ] ), - 'test.group.bar.1' => new ResourceLoaderTestModule( [ + ], + 'test.group.bar.1' => [ + 'class' => ResourceLoaderTestModule::class, 'group' => 'x-bar', - ] ), - 'test.group.bar.2' => new ResourceLoaderTestModule( [ + ], + 'test.group.bar.2' => [ + 'class' => ResourceLoaderTestModule::class, 'group' => 'x-bar', 'source' => 'example', - ] ), - 'test.target.foo' => new ResourceLoaderTestModule( [ + ], + 'test.target.foo' => [ + 'class' => ResourceLoaderTestModule::class, 'targets' => [ 'x-foo' ], - ] ), - 'test.target.bar' => new ResourceLoaderTestModule( [ + ], + 'test.target.bar' => [ + 'class' => ResourceLoaderTestModule::class, 'source' => 'example', 'targets' => [ 'x-foo' ], - ] ), + ], ], 'out' => ' -mw.loader.addSource( { +mw.loader.addSource({ "local": "/w/load.php", "example": "http://example.org/w/load.php" -} ); -mw.loader.register( [ +}); +mw.loader.register([ [ "test.blank", "{blankVer}" @@ -519,7 +661,7 @@ mw.loader.register( [ "x-bar", "example" ] -] );' +]);' ] ], ]; } @@ -554,8 +696,9 @@ mw.loader.register( [ public static function provideRegistrations() { return [ [ [ - 'test.blank' => new ResourceLoaderTestModule(), - 'test.min' => new ResourceLoaderTestModule( [ + 'test.blank' => [ 'class' => ResourceLoaderTestModule::class ], + 'test.min' => [ + 'class' => ResourceLoaderTestModule::class, 'skipFunction' => 'return !!(' . ' window.JSON &&' . @@ -565,7 +708,7 @@ mw.loader.register( [ 'dependencies' => [ 'test.blank', ], - ] ), + ], ] ] ]; } @@ -605,10 +748,10 @@ mw.loader.register( [ $rl->register( $modules ); $module = new ResourceLoaderStartUpModule(); $out = -'mw.loader.addSource( { +'mw.loader.addSource({ "local": "/w/load.php" -} ); -mw.loader.register( [ +}); +mw.loader.register([ [ "test.blank", "{blankVer}" @@ -623,7 +766,7 @@ mw.loader.register( [ null, "return !!( window.JSON \u0026\u0026 JSON.parse \u0026\u0026 JSON.stringify);" ] -] );'; +]);'; $this->assertEquals( self::expandPlaceholders( $out ), @@ -668,8 +811,8 @@ mw.loader.register( [ $context1 = $this->getResourceLoaderContext(); $rl1 = $context1->getResourceLoader(); $rl1->register( [ - 'test.a' => new ResourceLoaderTestModule(), - 'test.b' => new ResourceLoaderTestModule(), + 'test.a' => [ 'class' => ResourceLoaderTestModule::class ], + 'test.b' => [ 'class' => ResourceLoaderTestModule::class ], ] ); $module = new ResourceLoaderStartupModule(); $version1 = $module->getVersionHash( $context1 ); @@ -677,8 +820,8 @@ mw.loader.register( [ $context2 = $this->getResourceLoaderContext(); $rl2 = $context2->getResourceLoader(); $rl2->register( [ - 'test.b' => new ResourceLoaderTestModule(), - 'test.c' => new ResourceLoaderTestModule(), + 'test.b' => [ 'class' => ResourceLoaderTestModule::class ], + 'test.c' => [ 'class' => ResourceLoaderTestModule::class ], ] ); $module = new ResourceLoaderStartupModule(); $version2 = $module->getVersionHash( $context2 ); @@ -686,8 +829,11 @@ mw.loader.register( [ $context3 = $this->getResourceLoaderContext(); $rl3 = $context3->getResourceLoader(); $rl3->register( [ - 'test.a' => new ResourceLoaderTestModule(), - 'test.b' => new ResourceLoaderTestModule( [ 'script' => 'different' ] ), + 'test.a' => [ 'class' => ResourceLoaderTestModule::class ], + 'test.b' => [ + 'class' => ResourceLoaderTestModule::class, + 'script' => 'different', + ], ] ); $module = new ResourceLoaderStartupModule(); $version3 = $module->getVersionHash( $context3 ); @@ -713,7 +859,10 @@ mw.loader.register( [ $context = $this->getResourceLoaderContext(); $rl = $context->getResourceLoader(); $rl->register( [ - 'test.a' => new ResourceLoaderTestModule( [ 'dependencies' => [ 'x', 'y' ] ] ), + 'test.a' => [ + 'class' => ResourceLoaderTestModule::class, + 'dependencies' => [ 'x', 'y' ], + ], ] ); $module = new ResourceLoaderStartupModule(); $version1 = $module->getVersionHash( $context ); @@ -721,7 +870,10 @@ mw.loader.register( [ $context = $this->getResourceLoaderContext(); $rl = $context->getResourceLoader(); $rl->register( [ - 'test.a' => new ResourceLoaderTestModule( [ 'dependencies' => [ 'x', 'z' ] ] ), + 'test.a' => [ + 'class' => ResourceLoaderTestModule::class, + 'dependencies' => [ 'x', 'z' ], + ], ] ); $module = new ResourceLoaderStartupModule(); $version2 = $module->getVersionHash( $context ); diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php index 1171ebc85f..86c2e9f59b 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php @@ -70,28 +70,19 @@ class ResourceLoaderTest extends ResourceLoaderTestCase { $this->assertTrue( ResourceLoader::isValidModuleName( $name ) ); } - /** - * @covers ResourceLoader::register - * @covers ResourceLoader::getModule - */ - public function testRegisterValidObject() { - $module = new ResourceLoaderTestModule(); - $resourceLoader = new EmptyResourceLoader(); - $resourceLoader->register( 'test', $module ); - $this->assertEquals( $module, $resourceLoader->getModule( 'test' ) ); - } - /** * @covers ResourceLoader::register * @covers ResourceLoader::getModule */ public function testRegisterValidArray() { - $module = new ResourceLoaderTestModule(); $resourceLoader = new EmptyResourceLoader(); // Covers case of register() setting $rl->moduleInfos, // but $rl->modules lazy-populated by getModule() - $resourceLoader->register( 'test', [ 'object' => $module ] ); - $this->assertEquals( $module, $resourceLoader->getModule( 'test' ) ); + $resourceLoader->register( 'test', [ 'class' => ResourceLoaderTestModule::class ] ); + $this->assertInstanceOf( + ResourceLoaderTestModule::class, + $resourceLoader->getModule( 'test' ) + ); } /** @@ -99,10 +90,12 @@ class ResourceLoaderTest extends ResourceLoaderTestCase { * @group medium */ public function testRegisterEmptyString() { - $module = new ResourceLoaderTestModule(); $resourceLoader = new EmptyResourceLoader(); - $resourceLoader->register( '', $module ); - $this->assertEquals( $module, $resourceLoader->getModule( '' ) ); + $resourceLoader->register( '', [ 'class' => ResourceLoaderTestModule::class ] ); + $this->assertInstanceOf( + ResourceLoaderTestModule::class, + $resourceLoader->getModule( '' ) + ); } /** @@ -112,7 +105,7 @@ class ResourceLoaderTest extends ResourceLoaderTestCase { public function testRegisterInvalidName() { $resourceLoader = new EmptyResourceLoader(); $this->setExpectedException( MWException::class, "name 'test!invalid' is invalid" ); - $resourceLoader->register( 'test!invalid', new ResourceLoaderTestModule() ); + $resourceLoader->register( 'test!invalid', [] ); } /** @@ -120,7 +113,7 @@ class ResourceLoaderTest extends ResourceLoaderTestCase { */ public function testRegisterInvalidType() { $resourceLoader = new EmptyResourceLoader(); - $this->setExpectedException( MWException::class, 'ResourceLoader module info type error' ); + $this->setExpectedException( InvalidArgumentException::class, 'Invalid module info' ); $resourceLoader->register( 'test', new stdClass() ); } @@ -133,11 +126,13 @@ class ResourceLoaderTest extends ResourceLoaderTestCase { ->method( 'warning' ); $resourceLoader = new EmptyResourceLoader( null, $logger ); - $module1 = new ResourceLoaderTestModule(); - $module2 = new ResourceLoaderTestModule(); - $resourceLoader->register( 'test', $module1 ); - $resourceLoader->register( 'test', $module2 ); - $this->assertSame( $module2, $resourceLoader->getModule( 'test' ) ); + $resourceLoader->register( 'test', [ 'class' => ResourceLoaderSkinModule::class ] ); + $resourceLoader->register( 'test', [ 'class' => ResourceLoaderStartUpModule::class ] ); + $this->assertInstanceOf( + ResourceLoaderStartUpModule::class, + $resourceLoader->getModule( 'test' ), + 'last one wins' + ); } /** @@ -146,8 +141,8 @@ class ResourceLoaderTest extends ResourceLoaderTestCase { public function testGetModuleNames() { // Use an empty one so that core and extension modules don't get in. $resourceLoader = new EmptyResourceLoader(); - $resourceLoader->register( 'test.foo', new ResourceLoaderTestModule() ); - $resourceLoader->register( 'test.bar', new ResourceLoaderTestModule() ); + $resourceLoader->register( 'test.foo', [] ); + $resourceLoader->register( 'test.bar', [] ); $this->assertEquals( [ 'startup', 'test.foo', 'test.bar' ], $resourceLoader->getModuleNames() @@ -155,15 +150,21 @@ class ResourceLoaderTest extends ResourceLoaderTestCase { } public function provideTestIsFileModule() { - $fileModuleObj = $this->getMockBuilder( ResourceLoaderFileModule::class ) - ->disableOriginalConstructor() - ->getMock(); + $fileModuleObj = $this->createMock( ResourceLoaderFileModule::class ); return [ - 'object' => [ false, - new ResourceLoaderTestModule() + 'factory ignored' => [ false, + [ + 'factory' => function () { + return new ResourceLoaderTestModule(); + } + ] ], - 'FileModule object' => [ false, - $fileModuleObj + 'factory ignored (actual FileModule)' => [ false, + [ + 'factory' => function () use ( $fileModuleObj ) { + return $fileModuleObj; + } + ] ], 'simple empty' => [ true, [] @@ -171,9 +172,8 @@ class ResourceLoaderTest extends ResourceLoaderTestCase { 'simple scripts' => [ true, [ 'scripts' => 'example.js' ] ], - 'simple scripts, raw and targets' => [ true, [ + 'simple scripts with targets' => [ true, [ 'scripts' => [ 'a.js', 'b.js' ], - 'raw' => true, 'targets' => [ 'desktop', 'mobile' ], ] ], 'FileModule' => [ true, @@ -215,7 +215,7 @@ class ResourceLoaderTest extends ResourceLoaderTestCase { */ public function testIsModuleRegistered() { $rl = new EmptyResourceLoader(); - $rl->register( 'test', new ResourceLoaderTestModule() ); + $rl->register( 'test', [] ); $this->assertTrue( $rl->isModuleRegistered( 'test' ) ); $this->assertFalse( $rl->isModuleRegistered( 'test.unknown' ) ); } @@ -560,12 +560,12 @@ END */ public function testMakeLoaderRegisterScript() { $this->assertEquals( - 'mw.loader.register( [ + 'mw.loader.register([ [ "test.name", "1234567" ] -] );', +]);', ResourceLoader::makeLoaderRegisterScript( [ [ 'test.name', '1234567' ], ] ), @@ -573,7 +573,7 @@ END ); $this->assertEquals( - 'mw.loader.register( [ + 'mw.loader.register([ [ "test.foo", "100" @@ -601,7 +601,7 @@ END null, "return true;" ] -] );', +]);', ResourceLoader::makeLoaderRegisterScript( [ [ 'test.foo', '100' , [], null, null ], [ 'test.bar', '200', [ 'test.unknown' ], null ], @@ -617,29 +617,29 @@ END */ public function testMakeLoaderSourcesScript() { $this->assertEquals( - 'mw.loader.addSource( { + 'mw.loader.addSource({ "local": "/w/load.php" -} );', +});', ResourceLoader::makeLoaderSourcesScript( 'local', '/w/load.php' ) ); $this->assertEquals( - 'mw.loader.addSource( { + 'mw.loader.addSource({ "local": "/w/load.php" -} );', +});', ResourceLoader::makeLoaderSourcesScript( [ 'local' => '/w/load.php' ] ) ); $this->assertEquals( - 'mw.loader.addSource( { + 'mw.loader.addSource({ "local": "/w/load.php", "example": "https://example.org/w/load.php" -} );', +});', ResourceLoader::makeLoaderSourcesScript( [ 'local' => '/w/load.php', 'example' => 'https://example.org/w/load.php' ] ) ); $this->assertEquals( - 'mw.loader.addSource( [] );', + 'mw.loader.addSource([]);', ResourceLoader::makeLoaderSourcesScript( [] ) ); } @@ -710,9 +710,13 @@ END // Disable log from outputErrorAndLog ->setMethods( [ 'outputErrorAndLog' ] )->getMock(); $rl->register( [ - 'foo' => self::getSimpleModuleMock(), - 'ferry' => self::getFailFerryMock(), - 'bar' => self::getSimpleModuleMock(), + 'foo' => [ 'class' => ResourceLoaderTestModule::class ], + 'ferry' => [ + 'factory' => function () { + return self::getFailFerryMock(); + } + ], + 'bar' => [ 'class' => ResourceLoaderTestModule::class ], ] ); $context = $this->getResourceLoaderContext( [], $rl ); @@ -743,9 +747,9 @@ END 'modules' => [ 'foo' => 'foo()', ], - 'expected' => "foo()\n" . 'mw.loader.state( { + 'expected' => "foo()\n" . 'mw.loader.state({ "foo": "ready" -} );', +});', 'minified' => "foo()\n" . 'mw.loader.state({"foo":"ready"});', 'message' => 'Script without semi-colon', ], @@ -754,10 +758,10 @@ END 'foo' => 'foo()', 'bar' => 'bar()', ], - 'expected' => "foo()\nbar()\n" . 'mw.loader.state( { + 'expected' => "foo()\nbar()\n" . 'mw.loader.state({ "foo": "ready", "bar": "ready" -} );', +});', 'minified' => "foo()\nbar()\n" . 'mw.loader.state({"foo":"ready","bar":"ready"});', 'message' => 'Two scripts without semi-colon', ], @@ -765,9 +769,9 @@ END 'modules' => [ 'foo' => "foo()\n// bar();" ], - 'expected' => "foo()\n// bar();\n" . 'mw.loader.state( { + 'expected' => "foo()\n// bar();\n" . 'mw.loader.state({ "foo": "ready" -} );', +});', 'minified' => "foo()\n" . 'mw.loader.state({"foo":"ready"});', 'message' => 'Script with semi-colon in comment (T162719)', ], @@ -801,7 +805,6 @@ END $modules = array_map( function ( $script ) { return self::getSimpleModuleMock( $script ); }, $scripts ); - $rl->register( $modules ); $context = $this->getResourceLoaderContext( [ @@ -846,7 +849,6 @@ END 'bar' => self::getSimpleModuleMock( 'bar();' ), ]; $rl = new EmptyResourceLoader(); - $rl->register( $modules ); $context = $this->getResourceLoaderContext( [ 'modules' => 'foo|ferry|bar', @@ -864,11 +866,11 @@ END $this->assertCount( 1, $errors ); $this->assertRegExp( '/Ferry not found/', $errors[0] ); $this->assertEquals( - "foo();\nbar();\n" . 'mw.loader.state( { + "foo();\nbar();\n" . 'mw.loader.state({ "ferry": "error", "foo": "ready", "bar": "ready" -} );', +});', $response ); } @@ -886,7 +888,6 @@ END 'bar' => self::getSimpleStyleModuleMock( '.bar{}' ), ]; $rl = new EmptyResourceLoader(); - $rl->register( $modules ); $context = $this->getResourceLoaderContext( [ 'modules' => 'foo|ferry|bar', @@ -923,9 +924,15 @@ END // provide the full Config object here. $rl = new EmptyResourceLoader( MediaWikiServices::getInstance()->getMainConfig() ); $rl->register( [ - 'foo' => self::getSimpleModuleMock( 'foo();' ), - 'ferry' => self::getFailFerryMock(), - 'bar' => self::getSimpleModuleMock( 'bar();' ), + 'foo' => [ 'factory' => function () { + return self::getSimpleModuleMock( 'foo();' ); + } ], + 'ferry' => [ 'factory' => function () { + return self::getFailFerryMock(); + } ], + 'bar' => [ 'factory' => function () { + return self::getSimpleModuleMock( 'bar();' ); + } ], ] ); $context = $this->getResourceLoaderContext( [ @@ -982,15 +989,12 @@ END ] ); $rl = new EmptyResourceLoader(); - $rl->register( [ - 'foo' => $module, - ] ); $context = $this->getResourceLoaderContext( [ 'modules' => 'foo', 'only' => 'scripts' ], $rl ); - $modules = [ 'foo' => $rl->getModule( 'foo' ) ]; + $modules = [ 'foo' => $module ]; $response = $rl->makeModuleResponse( $context, $modules ); $extraHeaders = TestingAccessWrapper::newFromObject( $rl )->extraHeaders; @@ -1023,13 +1027,12 @@ END ] ); $rl = new EmptyResourceLoader(); - $rl->register( [ 'foo' => $foo, 'bar' => $bar ] ); $context = $this->getResourceLoaderContext( [ 'modules' => 'foo|bar', 'only' => 'scripts' ], $rl ); - $modules = [ 'foo' => $rl->getModule( 'foo' ), 'bar' => $rl->getModule( 'bar' ) ]; + $modules = [ 'foo' => $foo, 'bar' => $bar ]; $response = $rl->makeModuleResponse( $context, $modules ); $extraHeaders = TestingAccessWrapper::newFromObject( $rl )->extraHeaders; $this->assertEquals( @@ -1074,7 +1077,11 @@ END 'makeModuleResponse', ] ) ->getMock(); - $rl->register( 'test', $module ); + $rl->register( 'test', [ + 'factory' => function () use ( $module ) { + return $module; + } + ] ); $context = $this->getResourceLoaderContext( [ 'modules' => 'test', 'only' => null ], $rl @@ -1103,7 +1110,11 @@ END 'sendResponseHeaders', ] ) ->getMock(); - $rl->register( 'test', $module ); + $rl->register( 'test', [ + 'factory' => function () use ( $module ) { + return $module; + } + ] ); $context = $this->getResourceLoaderContext( [ 'modules' => 'test' ], $rl ); // Disable logging from outputErrorAndLog $this->setLogger( 'exception', new Psr\Log\NullLogger() ); diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderWikiModuleTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderWikiModuleTest.php index c1bdebec66..59649153e9 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderWikiModuleTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderWikiModuleTest.php @@ -4,10 +4,12 @@ use MediaWiki\MediaWikiServices; use Wikimedia\Rdbms\IDatabase; use Wikimedia\TestingAccessWrapper; +/** + * @covers ResourceLoaderWikiModule + */ class ResourceLoaderWikiModuleTest extends ResourceLoaderTestCase { /** - * @covers ResourceLoaderWikiModule::__construct * @dataProvider provideConstructor */ public function testConstructor( $params ) { @@ -15,6 +17,13 @@ class ResourceLoaderWikiModuleTest extends ResourceLoaderTestCase { $this->assertInstanceOf( ResourceLoaderWikiModule::class, $module ); } + public static function provideConstructor() { + yield 'null' => [ null ]; + yield 'empty' => [ [] ]; + yield 'unknown settings' => [ [ 'foo' => 'baz' ] ]; + yield 'real settings' => [ [ 'MediaWiki:Common.js' ] ]; + } + private function prepareTitleInfo( array $mockInfo ) { $module = TestingAccessWrapper::newFromClass( ResourceLoaderWikiModule::class ); $info = []; @@ -24,21 +33,8 @@ class ResourceLoaderWikiModuleTest extends ResourceLoaderTestCase { return $info; } - public static function provideConstructor() { - return [ - // Nothing - [ null ], - [ [] ], - // Unrecognized settings - [ [ 'foo' => 'baz' ] ], - // Real settings - [ [ 'scripts' => [ 'MediaWiki:Common.js' ] ] ], - ]; - } - /** * @dataProvider provideGetPages - * @covers ResourceLoaderWikiModule::getPages */ public function testGetPages( $params, Config $config, $expected ) { $module = new ResourceLoaderWikiModule( $params ); @@ -48,7 +44,7 @@ class ResourceLoaderWikiModuleTest extends ResourceLoaderTestCase { $getPages = new ReflectionMethod( $module, 'getPages' ); $getPages->setAccessible( true ); $out = $getPages->invoke( $module, ResourceLoaderContext::newDummyContext() ); - $this->assertEquals( $expected, $out ); + $this->assertSame( $expected, $out ); } public static function provideGetPages() { @@ -84,98 +80,131 @@ class ResourceLoaderWikiModuleTest extends ResourceLoaderTestCase { } /** - * @covers ResourceLoaderWikiModule::getGroup * @dataProvider provideGetGroup */ public function testGetGroup( $params, $expected ) { $module = new ResourceLoaderWikiModule( $params ); - $this->assertEquals( $expected, $module->getGroup() ); + $this->assertSame( $expected, $module->getGroup() ); } public static function provideGetGroup() { - return [ - // No group specified - [ [], null ], - // A random group - [ [ 'group' => 'foobar' ], 'foobar' ], + yield 'no group' => [ [], null ]; + yield 'some group' => [ [ 'group' => 'foobar' ], 'foobar' ]; + } + + /** + * @dataProvider provideGetType + */ + public function testGetType( $params, $expected ) { + $module = new ResourceLoaderWikiModule( $params ); + $this->assertSame( $expected, $module->getType() ); + } + + public static function provideGetType() { + yield 'empty' => [ + [], + ResourceLoaderWikiModule::LOAD_GENERAL, + ]; + yield 'scripts' => [ + [ 'scripts' => [ 'Example.js' ] ], + ResourceLoaderWikiModule::LOAD_GENERAL, + ]; + yield 'styles' => [ + [ 'styles' => [ 'Example.css' ] ], + ResourceLoaderWikiModule::LOAD_STYLES, + ]; + yield 'styles and scripts' => [ + [ 'styles' => [ 'Example.css' ], 'scripts' => [ 'Example.js' ] ], + ResourceLoaderWikiModule::LOAD_GENERAL, ]; } /** - * @covers ResourceLoaderWikiModule::isKnownEmpty * @dataProvider provideIsKnownEmpty */ public function testIsKnownEmpty( $titleInfo, $group, $dependencies, $expected ) { $module = $this->getMockBuilder( ResourceLoaderWikiModule::class ) - ->setMethods( [ 'getTitleInfo', 'getGroup', 'getDependencies' ] ) - ->getMock(); - $module->expects( $this->any() ) - ->method( 'getTitleInfo' ) - ->will( $this->returnValue( $this->prepareTitleInfo( $titleInfo ) ) ); - $module->expects( $this->any() ) - ->method( 'getGroup' ) - ->will( $this->returnValue( $group ) ); - $module->expects( $this->any() ) - ->method( 'getDependencies' ) - ->will( $this->returnValue( $dependencies ) ); - $context = $this->getMockBuilder( ResourceLoaderContext::class ) ->disableOriginalConstructor() + ->setMethods( [ 'getTitleInfo', 'getGroup', 'getDependencies' ] ) ->getMock(); - $this->assertEquals( $expected, $module->isKnownEmpty( $context ) ); + $module->method( 'getTitleInfo' ) + ->willReturn( $this->prepareTitleInfo( $titleInfo ) ); + $module->method( 'getGroup' ) + ->willReturn( $group ); + $module->method( 'getDependencies' ) + ->willReturn( $dependencies ); + $context = $this->createMock( ResourceLoaderContext::class ); + $this->assertSame( $expected, $module->isKnownEmpty( $context ) ); } public static function provideIsKnownEmpty() { - return [ - // No valid pages - [ [], 'test1', [], true ], - // 'site' module with a non-empty page - [ - [ 'MediaWiki:Common.js' => [ 'page_len' => 1234 ] ], - 'site', - [], - false, - ], - // 'site' module without existing pages but dependencies - [ - [], - 'site', - [ 'mobile.css' ], - false, - ], - // 'site' module which is empty but has dependencies - [ - [ 'MediaWiki:Common.js' => [ 'page_len' => 0 ] ], - 'site', - [ 'mobile.css' ], - false, - ], - // 'site' module with an empty page - [ - [ 'MediaWiki:Foo.js' => [ 'page_len' => 0 ] ], - 'site', - [], - false, - ], - // 'user' module with a non-empty page - [ - [ 'User:Example/common.js' => [ 'page_len' => 25 ] ], - 'user', - [], - false, - ], - // 'user' module with an empty page - [ - [ 'User:Example/foo.js' => [ 'page_len' => 0 ] ], - 'user', - [], - true, - ], + yield 'nothing' => [ + [], + null, + [], + // No pages exist, considered empty. + true, + ]; + + yield 'an empty page exists (no group)' => [ + [ 'Project:Example/foo.js' => [ 'page_len' => 0 ] ], + null, + [], + // There is an existing page, so we should let the module be queued. + // Its emptiness might be temporary, hence considered non-empty (T70488). + false, + ]; + yield 'an empty page exists (site group)' => [ + [ 'MediaWiki:Foo.js' => [ 'page_len' => 0 ] ], + 'site', + [], + // There is an existing page, hence considered non-empty. + false, + ]; + yield 'an empty page exists (user group)' => [ + [ 'User:Example/foo.js' => [ 'page_len' => 0 ] ], + 'user', + [], + // There is an existing page, but it is empty. + // For user-specific modules, don't bother loading a known-empty module. + // Given user-specific HTML output, this will vary and re-appear if/when + // the page becomes non-empty again. + true, + ]; + + yield 'no pages but having dependencies (no group)' => [ + [], + null, + [ 'another-module' ], + false, + ]; + yield 'no pages but having dependencies (site group)' => [ + [], + 'site', + [ 'another-module' ], + false, + ]; + yield 'no pages but having dependencies (user group)' => [ + [], + 'user', + [ 'another-module' ], + false, + ]; + + yield 'a non-empty page exists (user group)' => [ + [ 'User:Example/foo.js' => [ 'page_len' => 25 ] ], + 'user', + [], + false, + ]; + yield 'a non-empty page exists (site group)' => [ + [ 'MediaWiki:Foo.js' => [ 'page_len' => 25 ] ], + 'site', + [], + false, ]; } - /** - * @covers ResourceLoaderWikiModule::getTitleInfo - */ public function testGetTitleInfo() { $pages = [ 'MediaWiki:Common.css' => [ 'type' => 'styles' ], @@ -187,26 +216,20 @@ class ResourceLoaderWikiModuleTest extends ResourceLoaderTestCase { ] ); $expected = $titleInfo; - $module = $this->getMockBuilder( TestResourceLoaderWikiModule::class ) - ->setMethods( [ 'getPages' ] ) + $module = $this->getMockBuilder( ResourceLoaderWikiModule::class ) + ->setMethods( [ 'getPages', 'getTitleInfo' ] ) ->getMock(); $module->method( 'getPages' )->willReturn( $pages ); - // Can't mock static methods - $module::$returnFetchTitleInfo = $titleInfo; + $module->method( 'getTitleInfo' )->willReturn( $titleInfo ); $context = $this->getMockBuilder( ResourceLoaderContext::class ) ->disableOriginalConstructor() ->getMock(); $module = TestingAccessWrapper::newFromObject( $module ); - $this->assertEquals( $expected, $module->getTitleInfo( $context ), 'Title info' ); + $this->assertSame( $expected, $module->getTitleInfo( $context ), 'Title info' ); } - /** - * @covers ResourceLoaderWikiModule::getTitleInfo - * @covers ResourceLoaderWikiModule::setTitleInfo - * @covers ResourceLoaderWikiModule::preloadTitleInfo - */ public function testGetPreloadedTitleInfo() { $pages = [ 'MediaWiki:Common.css' => [ 'type' => 'styles' ], @@ -230,7 +253,6 @@ class ResourceLoaderWikiModuleTest extends ResourceLoaderTestCase { $module::$returnFetchTitleInfo = $titleInfo; $rl = new EmptyResourceLoader(); - $rl->register( 'testmodule', $module ); $context = new ResourceLoaderContext( $rl, new FauxRequest() ); TestResourceLoaderWikiModule::invalidateModuleCache( @@ -241,98 +263,85 @@ class ResourceLoaderWikiModuleTest extends ResourceLoaderTestCase { ); TestResourceLoaderWikiModule::preloadTitleInfo( $context, - wfGetDB( DB_REPLICA ), + $this->createMock( IDatabase::class ), [ 'testmodule' ] ); $module = TestingAccessWrapper::newFromObject( $module ); - $this->assertEquals( $expected, $module->getTitleInfo( $context ), 'Title info' ); + $this->assertSame( $expected, $module->getTitleInfo( $context ), 'Title info' ); } - /** - * @covers ResourceLoaderWikiModule::preloadTitleInfo - */ public function testGetPreloadedBadTitle() { - // Mock values - $pages = [ - // Covers else branch for invalid page name - '[x]' => [ 'type' => 'styles' ], - ]; - $titleInfo = []; - - // Set up objects - $module = $this->getMockBuilder( TestResourceLoaderWikiModule::class ) - ->setMethods( [ 'getPages' ] )->getMock(); - $module->method( 'getPages' )->willReturn( $pages ); - $module::$returnFetchTitleInfo = $titleInfo; + // Set up + TestResourceLoaderWikiModule::$returnFetchTitleInfo = []; $rl = new EmptyResourceLoader(); - $rl->register( 'testmodule', $module ); + $rl->getConfig()->set( 'UseSiteJs', true ); + $rl->getConfig()->set( 'UseSiteCss', true ); + $rl->register( 'testmodule', [ + 'class' => TestResourceLoaderWikiModule::class, + // Covers preloadTitleInfo branch for invalid page name + 'styles' => [ '[x]' ], + ] ); $context = new ResourceLoaderContext( $rl, new FauxRequest() ); // Act TestResourceLoaderWikiModule::preloadTitleInfo( $context, - wfGetDB( DB_REPLICA ), + $this->createMock( IDatabase::class ), [ 'testmodule' ] ); // Assert - $module = TestingAccessWrapper::newFromObject( $module ); - $this->assertEquals( $titleInfo, $module->getTitleInfo( $context ), 'Title info' ); + $module = TestingAccessWrapper::newFromObject( $rl->getModule( 'testmodule' ) ); + $this->assertSame( [], $module->getTitleInfo( $context ), 'Title info' ); } - /** - * @covers ResourceLoaderWikiModule::preloadTitleInfo - */ public function testGetPreloadedTitleInfoEmpty() { $context = new ResourceLoaderContext( new EmptyResourceLoader(), new FauxRequest() ); - // Covers early return + // This covers the early return case $this->assertSame( null, ResourceLoaderWikiModule::preloadTitleInfo( $context, - wfGetDB( DB_REPLICA ), + $this->createMock( IDatabase::class ), [] ) ); } public static function provideGetContent() { - return [ - 'Bad title' => [ null, '[x]' ], - 'Dead redirect' => [ null, [ - 'text' => 'Dead redirect', - 'title' => 'Dead_redirect', - 'redirect' => 1, - ] ], - 'Bad content model' => [ null, [ - 'text' => 'MediaWiki:Wikitext', - 'ns' => NS_MEDIAWIKI, - 'title' => 'Wikitext', - ] ], - 'No JS content found' => [ null, [ - 'text' => 'MediaWiki:Script.js', - 'ns' => NS_MEDIAWIKI, - 'title' => 'Script.js', - ] ], - 'No CSS content found' => [ null, [ - 'text' => 'MediaWiki:Styles.css', - 'ns' => NS_MEDIAWIKI, - 'title' => 'Script.css', - ] ], - ]; + yield 'Bad title' => [ null, '[x]' ]; + yield 'Dead redirect' => [ null, [ + 'text' => 'Dead redirect', + 'title' => 'Dead_redirect', + 'redirect' => 1, + ] ]; + yield 'Bad content model' => [ null, [ + 'text' => 'MediaWiki:Wikitext', + 'ns' => NS_MEDIAWIKI, + 'title' => 'Wikitext', + ] ]; + yield 'No JS content found' => [ null, [ + 'text' => 'MediaWiki:Script.js', + 'ns' => NS_MEDIAWIKI, + 'title' => 'Script.js', + ] ]; + yield 'No CSS content found' => [ null, [ + 'text' => 'MediaWiki:Styles.css', + 'ns' => NS_MEDIAWIKI, + 'title' => 'Script.css', + ] ]; } /** - * @covers ResourceLoaderWikiModule::getContent * @dataProvider provideGetContent */ public function testGetContent( $expected, $title ) { $context = $this->getResourceLoaderContext( [], new EmptyResourceLoader ); $module = $this->getMockBuilder( ResourceLoaderWikiModule::class ) ->setMethods( [ 'getContentObj' ] )->getMock(); - $module->expects( $this->any() ) - ->method( 'getContentObj' )->willReturn( null ); + $module->method( 'getContentObj' ) + ->willReturn( null ); if ( is_array( $title ) ) { $title += [ 'ns' => NS_MAIN, 'id' => 1, 'len' => 1, 'redirect' => 0 ]; @@ -349,29 +358,23 @@ class ResourceLoaderWikiModuleTest extends ResourceLoaderTestCase { } $module = TestingAccessWrapper::newFromObject( $module ); - $this->assertEquals( + $this->assertSame( $expected, $module->getContent( $titleText, $context ) ); } - /** - * @covers ResourceLoaderWikiModule::getContent - * @covers ResourceLoaderWikiModule::getContentObj - * @covers ResourceLoaderWikiModule::shouldEmbedModule - */ public function testContentOverrides() { $pages = [ 'MediaWiki:Common.css' => [ 'type' => 'style' ], ]; - $module = $this->getMockBuilder( TestResourceLoaderWikiModule::class ) + $module = $this->getMockBuilder( ResourceLoaderWikiModule::class ) ->setMethods( [ 'getPages' ] ) ->getMock(); $module->method( 'getPages' )->willReturn( $pages ); $rl = new EmptyResourceLoader(); - $rl->register( 'testmodule', $module ); $context = new DerivativeResourceLoaderContext( new ResourceLoaderContext( $rl, new FauxRequest() ) ); @@ -383,7 +386,7 @@ class ResourceLoaderWikiModuleTest extends ResourceLoaderTestCase { } ); $this->assertTrue( $module->shouldEmbedModule( $context ) ); - $this->assertEquals( [ + $this->assertSame( [ 'all' => [ "/*\nMediaWiki:Common.css\n*/\n.override{}" ] @@ -398,10 +401,6 @@ class ResourceLoaderWikiModuleTest extends ResourceLoaderTestCase { $this->assertFalse( $module->shouldEmbedModule( $context ) ); } - /** - * @covers ResourceLoaderWikiModule::getContent - * @covers ResourceLoaderWikiModule::getContentObj - */ public function testGetContentForRedirects() { // Set up context and module object $context = new DerivativeResourceLoaderContext( @@ -410,11 +409,10 @@ class ResourceLoaderWikiModuleTest extends ResourceLoaderTestCase { $module = $this->getMockBuilder( ResourceLoaderWikiModule::class ) ->setMethods( [ 'getPages' ] ) ->getMock(); - $module->expects( $this->any() ) - ->method( 'getPages' ) - ->will( $this->returnValue( [ + $module->method( 'getPages' ) + ->willReturn( [ 'MediaWiki:Redirect.js' => [ 'type' => 'script' ] - ] ) ); + ] ); $context->setContentOverrideCallback( function ( Title $title ) { if ( $title->getPrefixedText() === 'MediaWiki:Redirect.js' ) { $handler = new JavaScriptContentHandler(); @@ -436,14 +434,14 @@ class ResourceLoaderWikiModuleTest extends ResourceLoaderTestCase { 1 // redirect ); - $this->assertEquals( + $this->assertSame( "/*\nMediaWiki:Redirect.js\n*/\ntarget;\n", $module->getScript( $context ), 'Redirect resolved by getContent' ); } - function tearDown() { + public function tearDown() { Title::clearCaches(); parent::tearDown(); } diff --git a/tests/phpunit/includes/search/SearchEngineTest.php b/tests/phpunit/includes/search/SearchEngineTest.php index 0c6520eed5..d66e480c05 100644 --- a/tests/phpunit/includes/search/SearchEngineTest.php +++ b/tests/phpunit/includes/search/SearchEngineTest.php @@ -1,5 +1,7 @@ true, 'wgCapitalLinkOverrides' => [ NS_CATEGORY => false // for testCompletionSearchMustRespectCapitalLinkOverrides - ] + ], ] ); - $this->search = new $searchType( $this->db ); + $lb = LoadBalancerSingle::newFromConnection( $this->db ); + $this->search = new $searchType( $lb ); } protected function tearDown() { @@ -61,7 +64,7 @@ class SearchEngineTest extends MediaWikiLangTestCase { 'wgCapitalLinks' => true, 'wgCapitalLinkOverrides' => [ NS_CATEGORY => false // for testCompletionSearchMustRespectCapitalLinkOverrides - ] + ], ] ); $this->insertPage( 'Not_Main_Page', 'This is not a main page' ); @@ -187,7 +190,7 @@ class SearchEngineTest extends MediaWikiLangTestCase { $match = $res->getIterator()->current(); $snippet = "A " . $phrase . ""; $this->assertStringStartsWith( $snippet, - $match->getTextSnippet( $res->termMatches() ), + $match->getTextSnippet(), "Highlight a phrase search" ); } @@ -280,7 +283,7 @@ class SearchEngineTest extends MediaWikiLangTestCase { $mockField = $this->getMockBuilder( SearchIndexFieldDefinition::class )->setConstructorArgs( [ $name, - $type + $type, ] )->getMock(); $mockField->expects( $this->any() )->method( 'getMapping' )->willReturn( [ @@ -343,8 +346,9 @@ class SearchEngineTest extends MediaWikiLangTestCase { $setAugmentor = $this->createMock( ResultSetAugmentor::class ); $setAugmentor->expects( $this->once() ) ->method( 'augmentAll' ) - ->willReturnCallback( function ( SearchResultSet $resultSet ) { + ->willReturnCallback( function ( ISearchResultSet $resultSet ) { $data = []; + /** @var SearchResult $result */ foreach ( $resultSet as $result ) { $id = $result->getTitle()->getArticleID(); $data[$id] = "Result:$id:" . $result->getTitle()->getText(); @@ -402,7 +406,7 @@ class SearchEngineTest extends MediaWikiLangTestCase { [ 'query' => 'foo', ], - false + false, ], 'empty' => [ [ @@ -442,34 +446,34 @@ class SearchEngineTest extends MediaWikiLangTestCase { 'query' => 'all:test', 'withAll' => false, ], - false + false, ], 'ns only' => [ [ 'query' => 'help:', ], - [ '', [ NS_HELP ] ] + [ '', [ NS_HELP ] ], ], 'all only' => [ [ 'query' => 'all:', 'withAll' => true, ], - [ '', null ] + [ '', null ], ], 'all wins over namespace when first' => [ [ 'query' => 'all:help:test', 'withAll' => true, ], - [ 'help:test', null ] + [ 'help:test', null ], ], 'ns wins over all when first' => [ [ 'query' => 'help:all:test', 'withAll' => true, ], - [ 'all:test', [ NS_HELP ] ] + [ 'all:test', [ NS_HELP ] ], ], ]; } diff --git a/tests/phpunit/includes/search/SearchIndexFieldTest.php b/tests/phpunit/includes/search/SearchIndexFieldTest.php deleted file mode 100644 index 8b4119e0d3..0000000000 --- a/tests/phpunit/includes/search/SearchIndexFieldTest.php +++ /dev/null @@ -1,56 +0,0 @@ -getMockBuilder( SearchIndexFieldDefinition::class ) - ->setMethods( [ 'getMapping' ] ) - ->setConstructorArgs( [ $n1, $t1 ] ) - ->getMock(); - $field2 = - $this->getMockBuilder( SearchIndexFieldDefinition::class ) - ->setMethods( [ 'getMapping' ] ) - ->setConstructorArgs( [ $n2, $t2 ] ) - ->getMock(); - - if ( $result ) { - $this->assertNotFalse( $field1->merge( $field2 ) ); - } else { - $this->assertFalse( $field1->merge( $field2 ) ); - } - - $field1->setFlag( 0xFF ); - $this->assertFalse( $field1->merge( $field2 ) ); - - $field1->setMergeCallback( - function ( $a, $b ) { - return "test"; - } - ); - $this->assertEquals( "test", $field1->merge( $field2 ) ); - } - -} diff --git a/tests/phpunit/includes/search/SearchSuggestionSetTest.php b/tests/phpunit/includes/search/SearchSuggestionSetTest.php deleted file mode 100644 index 02fa5e9cac..0000000000 --- a/tests/phpunit/includes/search/SearchSuggestionSetTest.php +++ /dev/null @@ -1,111 +0,0 @@ -assertEquals( 0, $set->getSize() ); - $set->append( new SearchSuggestion( 3 ) ); - $this->assertEquals( 3, $set->getWorstScore() ); - $this->assertEquals( 3, $set->getBestScore() ); - - $suggestion = new SearchSuggestion( 4 ); - $set->append( $suggestion ); - $this->assertEquals( 2, $set->getWorstScore() ); - $this->assertEquals( 3, $set->getBestScore() ); - $this->assertEquals( 2, $suggestion->getScore() ); - - $suggestion = new SearchSuggestion( 2 ); - $set->append( $suggestion ); - $this->assertEquals( 1, $set->getWorstScore() ); - $this->assertEquals( 3, $set->getBestScore() ); - $this->assertEquals( 1, $suggestion->getScore() ); - - $scores = $set->map( function ( $s ) { - return $s->getScore(); - } ); - $sorted = $scores; - asort( $sorted ); - $this->assertEquals( $sorted, $scores ); - } - - /** - * Test that adding a new best suggestion will keep proper score - * ordering - * @covers SearchSuggestionSet::getWorstScore - * @covers SearchSuggestionSet::getBestScore - * @covers SearchSuggestionSet::prepend - */ - public function testInsertBest() { - $set = SearchSuggestionSet::emptySuggestionSet(); - $this->assertEquals( 0, $set->getSize() ); - $set->prepend( new SearchSuggestion( 3 ) ); - $this->assertEquals( 3, $set->getWorstScore() ); - $this->assertEquals( 3, $set->getBestScore() ); - - $suggestion = new SearchSuggestion( 4 ); - $set->prepend( $suggestion ); - $this->assertEquals( 3, $set->getWorstScore() ); - $this->assertEquals( 4, $set->getBestScore() ); - $this->assertEquals( 4, $suggestion->getScore() ); - - $suggestion = new SearchSuggestion( 0 ); - $set->prepend( $suggestion ); - $this->assertEquals( 3, $set->getWorstScore() ); - $this->assertEquals( 5, $set->getBestScore() ); - $this->assertEquals( 5, $suggestion->getScore() ); - - $suggestion = new SearchSuggestion( 2 ); - $set->prepend( $suggestion ); - $this->assertEquals( 3, $set->getWorstScore() ); - $this->assertEquals( 6, $set->getBestScore() ); - $this->assertEquals( 6, $suggestion->getScore() ); - - $scores = $set->map( function ( $s ) { - return $s->getScore(); - } ); - $sorted = $scores; - asort( $sorted ); - $this->assertEquals( $sorted, $scores ); - } - - /** - * @covers SearchSuggestionSet::shrink - */ - public function testShrink() { - $set = SearchSuggestionSet::emptySuggestionSet(); - for ( $i = 0; $i < 100; $i++ ) { - $set->append( new SearchSuggestion( 0 ) ); - } - $set->shrink( 10 ); - $this->assertEquals( 10, $set->getSize() ); - - $set->shrink( 0 ); - $this->assertEquals( 0, $set->getSize() ); - } - - // TODO: test for fromTitles -} diff --git a/tests/phpunit/includes/session/MetadataMergeExceptionTest.php b/tests/phpunit/includes/session/MetadataMergeExceptionTest.php deleted file mode 100644 index 8cb4302a4e..0000000000 --- a/tests/phpunit/includes/session/MetadataMergeExceptionTest.php +++ /dev/null @@ -1,30 +0,0 @@ - 'bar' ]; - - $ex = new MetadataMergeException(); - $this->assertInstanceOf( \UnexpectedValueException::class, $ex ); - $this->assertSame( [], $ex->getContext() ); - - $ex2 = new MetadataMergeException( 'Message', 42, $ex, $data ); - $this->assertSame( 'Message', $ex2->getMessage() ); - $this->assertSame( 42, $ex2->getCode() ); - $this->assertSame( $ex, $ex2->getPrevious() ); - $this->assertSame( $data, $ex2->getContext() ); - - $ex->setContext( $data ); - $this->assertSame( $data, $ex->getContext() ); - } - -} diff --git a/tests/phpunit/includes/session/SessionIdTest.php b/tests/phpunit/includes/session/SessionIdTest.php deleted file mode 100644 index 2b06d971a6..0000000000 --- a/tests/phpunit/includes/session/SessionIdTest.php +++ /dev/null @@ -1,22 +0,0 @@ -assertSame( 'foo', $id->getId() ); - $this->assertSame( 'foo', (string)$id ); - $id->setId( 'bar' ); - $this->assertSame( 'bar', $id->getId() ); - $this->assertSame( 'bar', (string)$id ); - } - -} diff --git a/tests/phpunit/includes/session/SessionManagerTest.php b/tests/phpunit/includes/session/SessionManagerTest.php index b33cd24a34..cd0867d2ef 100644 --- a/tests/phpunit/includes/session/SessionManagerTest.php +++ b/tests/phpunit/includes/session/SessionManagerTest.php @@ -711,10 +711,10 @@ class SessionManagerTest extends MediaWikiTestCase { ] ); $expect = [ - 'Foo' => [], - 'Bar' => [ 'X', 'Bar1', 3 => 'Bar2' ], - 'Quux' => [ 'Quux' ], - 'Baz' => [], + 'Foo' => null, + 'Bar' => null, + 'Quux' => null, + 'Baz' => null, ]; $this->assertEquals( $expect, $manager->getVaryHeaders() ); diff --git a/tests/phpunit/includes/session/SessionTest.php b/tests/phpunit/includes/session/SessionTest.php index a74056d0ca..0031cb3f55 100644 --- a/tests/phpunit/includes/session/SessionTest.php +++ b/tests/phpunit/includes/session/SessionTest.php @@ -13,214 +13,6 @@ use Wikimedia\TestingAccessWrapper; */ class SessionTest extends MediaWikiTestCase { - public function testConstructor() { - $backend = TestUtils::getDummySessionBackend(); - TestingAccessWrapper::newFromObject( $backend )->requests = [ -1 => 'dummy' ]; - TestingAccessWrapper::newFromObject( $backend )->id = new SessionId( 'abc' ); - - $session = new Session( $backend, 42, new \TestLogger ); - $priv = TestingAccessWrapper::newFromObject( $session ); - $this->assertSame( $backend, $priv->backend ); - $this->assertSame( 42, $priv->index ); - - $request = new \FauxRequest(); - $priv2 = TestingAccessWrapper::newFromObject( $session->sessionWithRequest( $request ) ); - $this->assertSame( $backend, $priv2->backend ); - $this->assertNotSame( $priv->index, $priv2->index ); - $this->assertSame( $request, $priv2->getRequest() ); - } - - /** - * @dataProvider provideMethods - * @param string $m Method to test - * @param array $args Arguments to pass to the method - * @param bool $index Whether the backend method gets passed the index - * @param bool $ret Whether the method returns a value - */ - public function testMethods( $m, $args, $index, $ret ) { - $mock = $this->getMockBuilder( DummySessionBackend::class ) - ->setMethods( [ $m, 'deregisterSession' ] ) - ->getMock(); - $mock->expects( $this->once() )->method( 'deregisterSession' ) - ->with( $this->identicalTo( 42 ) ); - - $tmp = $mock->expects( $this->once() )->method( $m ); - $expectArgs = []; - if ( $index ) { - $expectArgs[] = $this->identicalTo( 42 ); - } - foreach ( $args as $arg ) { - $expectArgs[] = $this->identicalTo( $arg ); - } - $tmp = call_user_func_array( [ $tmp, 'with' ], $expectArgs ); - - $retval = new \stdClass; - $tmp->will( $this->returnValue( $retval ) ); - - $session = TestUtils::getDummySession( $mock, 42 ); - - if ( $ret ) { - $this->assertSame( $retval, call_user_func_array( [ $session, $m ], $args ) ); - } else { - $this->assertNull( call_user_func_array( [ $session, $m ], $args ) ); - } - - // Trigger Session destructor - $session = null; - } - - public static function provideMethods() { - return [ - [ 'getId', [], false, true ], - [ 'getSessionId', [], false, true ], - [ 'resetId', [], false, true ], - [ 'getProvider', [], false, true ], - [ 'isPersistent', [], false, true ], - [ 'persist', [], false, false ], - [ 'unpersist', [], false, false ], - [ 'shouldRememberUser', [], false, true ], - [ 'setRememberUser', [ true ], false, false ], - [ 'getRequest', [], true, true ], - [ 'getUser', [], false, true ], - [ 'getAllowedUserRights', [], false, true ], - [ 'canSetUser', [], false, true ], - [ 'setUser', [ new \stdClass ], false, false ], - [ 'suggestLoginUsername', [], true, true ], - [ 'shouldForceHTTPS', [], false, true ], - [ 'setForceHTTPS', [ true ], false, false ], - [ 'getLoggedOutTimestamp', [], false, true ], - [ 'setLoggedOutTimestamp', [ 123 ], false, false ], - [ 'getProviderMetadata', [], false, true ], - [ 'save', [], false, false ], - [ 'delaySave', [], false, true ], - [ 'renew', [], false, false ], - ]; - } - - public function testDataAccess() { - $session = TestUtils::getDummySession(); - $backend = TestingAccessWrapper::newFromObject( $session )->backend; - - $this->assertEquals( 1, $session->get( 'foo' ) ); - $this->assertEquals( 'zero', $session->get( 0 ) ); - $this->assertFalse( $backend->dirty ); - - $this->assertEquals( null, $session->get( 'null' ) ); - $this->assertEquals( 'default', $session->get( 'null', 'default' ) ); - $this->assertFalse( $backend->dirty ); - - $session->set( 'foo', 55 ); - $this->assertEquals( 55, $backend->data['foo'] ); - $this->assertTrue( $backend->dirty ); - $backend->dirty = false; - - $session->set( 1, 'one' ); - $this->assertEquals( 'one', $backend->data[1] ); - $this->assertTrue( $backend->dirty ); - $backend->dirty = false; - - $session->set( 1, 'one' ); - $this->assertFalse( $backend->dirty ); - - $this->assertTrue( $session->exists( 'foo' ) ); - $this->assertTrue( $session->exists( 1 ) ); - $this->assertFalse( $session->exists( 'null' ) ); - $this->assertFalse( $session->exists( 100 ) ); - $this->assertFalse( $backend->dirty ); - - $session->remove( 'foo' ); - $this->assertArrayNotHasKey( 'foo', $backend->data ); - $this->assertTrue( $backend->dirty ); - $backend->dirty = false; - $session->remove( 1 ); - $this->assertArrayNotHasKey( 1, $backend->data ); - $this->assertTrue( $backend->dirty ); - $backend->dirty = false; - - $session->remove( 101 ); - $this->assertFalse( $backend->dirty ); - - $backend->data = [ 'a', 'b', '?' => 'c' ]; - $this->assertSame( 3, $session->count() ); - $this->assertSame( 3, count( $session ) ); - $this->assertFalse( $backend->dirty ); - - $data = []; - foreach ( $session as $key => $value ) { - $data[$key] = $value; - } - $this->assertEquals( $backend->data, $data ); - $this->assertFalse( $backend->dirty ); - - $this->assertEquals( $backend->data, iterator_to_array( $session ) ); - $this->assertFalse( $backend->dirty ); - } - - public function testArrayAccess() { - $logger = new \TestLogger; - $session = TestUtils::getDummySession( null, -1, $logger ); - $backend = TestingAccessWrapper::newFromObject( $session )->backend; - - $this->assertEquals( 1, $session['foo'] ); - $this->assertEquals( 'zero', $session[0] ); - $this->assertFalse( $backend->dirty ); - - $logger->setCollect( true ); - $this->assertEquals( null, $session['null'] ); - $logger->setCollect( false ); - $this->assertFalse( $backend->dirty ); - $this->assertSame( [ - [ LogLevel::DEBUG, 'Undefined index (auto-adds to session with a null value): null' ] - ], $logger->getBuffer() ); - $logger->clearBuffer(); - - $session['foo'] = 55; - $this->assertEquals( 55, $backend->data['foo'] ); - $this->assertTrue( $backend->dirty ); - $backend->dirty = false; - - $session[1] = 'one'; - $this->assertEquals( 'one', $backend->data[1] ); - $this->assertTrue( $backend->dirty ); - $backend->dirty = false; - - $session[1] = 'one'; - $this->assertFalse( $backend->dirty ); - - $session['bar'] = [ 'baz' => [] ]; - $session['bar']['baz']['quux'] = 2; - $this->assertEquals( [ 'baz' => [ 'quux' => 2 ] ], $backend->data['bar'] ); - - $logger->setCollect( true ); - $session['bar2']['baz']['quux'] = 3; - $logger->setCollect( false ); - $this->assertEquals( [ 'baz' => [ 'quux' => 3 ] ], $backend->data['bar2'] ); - $this->assertSame( [ - [ LogLevel::DEBUG, 'Undefined index (auto-adds to session with a null value): bar2' ] - ], $logger->getBuffer() ); - $logger->clearBuffer(); - - $backend->dirty = false; - $this->assertTrue( isset( $session['foo'] ) ); - $this->assertTrue( isset( $session[1] ) ); - $this->assertFalse( isset( $session['null'] ) ); - $this->assertFalse( isset( $session['missing'] ) ); - $this->assertFalse( isset( $session[100] ) ); - $this->assertFalse( $backend->dirty ); - - unset( $session['foo'] ); - $this->assertArrayNotHasKey( 'foo', $backend->data ); - $this->assertTrue( $backend->dirty ); - $backend->dirty = false; - unset( $session[1] ); - $this->assertArrayNotHasKey( 1, $backend->data ); - $this->assertTrue( $backend->dirty ); - $backend->dirty = false; - - unset( $session[101] ); - $this->assertFalse( $backend->dirty ); - } - public function testClear() { $session = TestUtils::getDummySession(); $priv = TestingAccessWrapper::newFromObject( $session ); @@ -268,66 +60,6 @@ class SessionTest extends MediaWikiTestCase { $this->assertTrue( $backend->dirty ); } - public function testTokens() { - $session = TestUtils::getDummySession(); - $priv = TestingAccessWrapper::newFromObject( $session ); - $backend = $priv->backend; - - $token = TestingAccessWrapper::newFromObject( $session->getToken() ); - $this->assertArrayHasKey( 'wsTokenSecrets', $backend->data ); - $this->assertArrayHasKey( 'default', $backend->data['wsTokenSecrets'] ); - $secret = $backend->data['wsTokenSecrets']['default']; - $this->assertSame( $secret, $token->secret ); - $this->assertSame( '', $token->salt ); - $this->assertTrue( $token->wasNew() ); - - $token = TestingAccessWrapper::newFromObject( $session->getToken( 'foo' ) ); - $this->assertSame( $secret, $token->secret ); - $this->assertSame( 'foo', $token->salt ); - $this->assertFalse( $token->wasNew() ); - - $backend->data['wsTokenSecrets']['secret'] = 'sekret'; - $token = TestingAccessWrapper::newFromObject( - $session->getToken( [ 'bar', 'baz' ], 'secret' ) - ); - $this->assertSame( 'sekret', $token->secret ); - $this->assertSame( 'bar|baz', $token->salt ); - $this->assertFalse( $token->wasNew() ); - - $session->resetToken( 'secret' ); - $this->assertArrayHasKey( 'wsTokenSecrets', $backend->data ); - $this->assertArrayHasKey( 'default', $backend->data['wsTokenSecrets'] ); - $this->assertArrayNotHasKey( 'secret', $backend->data['wsTokenSecrets'] ); - - $session->resetAllTokens(); - $this->assertArrayNotHasKey( 'wsTokenSecrets', $backend->data ); - } - - /** - * @dataProvider provideSecretsRoundTripping - * @param mixed $data - */ - public function testSecretsRoundTripping( $data ) { - $session = TestUtils::getDummySession(); - - // Simple round-trip - $session->setSecret( 'secret', $data ); - $this->assertNotEquals( $data, $session->get( 'secret' ) ); - $this->assertEquals( $data, $session->getSecret( 'secret', 'defaulted' ) ); - } - - public static function provideSecretsRoundTripping() { - return [ - [ 'Foobar' ], - [ 42 ], - [ [ 'foo', 'bar' => 'baz', 'subarray' => [ 1, 2, 3 ] ] ], - [ (object)[ 'foo', 'bar' => 'baz', 'subarray' => [ 1, 2, 3 ] ] ], - [ true ], - [ false ], - [ null ], - ]; - } - public function testSecrets() { $logger = new \TestLogger; $session = TestUtils::getDummySession( null, -1, $logger ); @@ -370,4 +102,29 @@ class SessionTest extends MediaWikiTestCase { \Wikimedia\restoreWarnings(); } + /** + * @dataProvider provideSecretsRoundTripping + * @param mixed $data + */ + public function testSecretsRoundTripping( $data ) { + $session = TestUtils::getDummySession(); + + // Simple round-trip + $session->setSecret( 'secret', $data ); + $this->assertNotEquals( $data, $session->get( 'secret' ) ); + $this->assertEquals( $data, $session->getSecret( 'secret', 'defaulted' ) ); + } + + public static function provideSecretsRoundTripping() { + return [ + [ 'Foobar' ], + [ 42 ], + [ [ 'foo', 'bar' => 'baz', 'subarray' => [ 1, 2, 3 ] ] ], + [ (object)[ 'foo', 'bar' => 'baz', 'subarray' => [ 1, 2, 3 ] ] ], + [ true ], + [ false ], + [ null ], + ]; + } + } diff --git a/tests/phpunit/includes/session/TestBagOStuff.php b/tests/phpunit/includes/session/TestBagOStuff.php index f9e30f06ab..64148b02e4 100644 --- a/tests/phpunit/includes/session/TestBagOStuff.php +++ b/tests/phpunit/includes/session/TestBagOStuff.php @@ -2,13 +2,17 @@ namespace MediaWiki\Session; +use CachedBagOStuff; +use HashBagOStuff; +use RequestContext; + /** * BagOStuff with utility functions for MediaWiki\\Session\\* testing */ -class TestBagOStuff extends \CachedBagOStuff { +class TestBagOStuff extends CachedBagOStuff { public function __construct() { - parent::__construct( new \HashBagOStuff ); + parent::__construct( new HashBagOStuff ); } /** @@ -51,7 +55,7 @@ class TestBagOStuff extends \CachedBagOStuff { * @param array|mixed $blob Session metadata and data */ public function setRawSession( $id, $blob ) { - $expiry = \RequestContext::getMain()->getConfig()->get( 'ObjectCacheSessionExpiry' ); + $expiry = RequestContext::getMain()->getConfig()->get( 'ObjectCacheSessionExpiry' ); $this->set( $this->makeKey( 'MWSession', $id ), $blob, $expiry ); } diff --git a/tests/phpunit/includes/session/TokenTest.php b/tests/phpunit/includes/session/TokenTest.php deleted file mode 100644 index 47976527ca..0000000000 --- a/tests/phpunit/includes/session/TokenTest.php +++ /dev/null @@ -1,67 +0,0 @@ -getMockBuilder( Token::class ) - ->setMethods( [ 'toStringAtTimestamp' ] ) - ->setConstructorArgs( [ 'sekret', 'salty', true ] ) - ->getMock(); - $token->expects( $this->any() )->method( 'toStringAtTimestamp' ) - ->will( $this->returnValue( 'faketoken+\\' ) ); - - $this->assertSame( 'faketoken+\\', $token->toString() ); - $this->assertSame( 'faketoken+\\', (string)$token ); - $this->assertTrue( $token->wasNew() ); - - $token = new Token( 'sekret', 'salty', false ); - $this->assertFalse( $token->wasNew() ); - } - - public function testToStringAtTimestamp() { - $token = TestingAccessWrapper::newFromObject( new Token( 'sekret', 'salty', false ) ); - - $this->assertSame( - 'd9ade0c7d4349e9df9094e61c33a5a0d5644fde2+\\', - $token->toStringAtTimestamp( 1447362018 ) - ); - $this->assertSame( - 'ee2f7a2488dea9176c224cfb400d43be5644fdea+\\', - $token->toStringAtTimestamp( 1447362026 ) - ); - } - - public function testGetTimestamp() { - $this->assertSame( - 1447362018, Token::getTimestamp( 'd9ade0c7d4349e9df9094e61c33a5a0d5644fde2+\\' ) - ); - $this->assertSame( - 1447362026, Token::getTimestamp( 'ee2f7a2488dea9176c224cfb400d43be5644fdea+\\' ) - ); - $this->assertNull( Token::getTimestamp( 'ee2f7a2488dea9176c224cfb400d43be5644fdea-\\' ) ); - $this->assertNull( Token::getTimestamp( 'ee2f7a2488dea9176c224cfb400d43be+\\' ) ); - - $this->assertNull( Token::getTimestamp( 'ee2f7a2488dea9x76c224cfb400d43be5644fdea+\\' ) ); - } - - public function testMatch() { - $token = TestingAccessWrapper::newFromObject( new Token( 'sekret', 'salty', false ) ); - - $test = $token->toStringAtTimestamp( time() - 10 ); - $this->assertTrue( $token->match( $test ) ); - $this->assertTrue( $token->match( $test, 12 ) ); - $this->assertFalse( $token->match( $test, 8 ) ); - - $this->assertFalse( $token->match( 'ee2f7a2488dea9176c224cfb400d43be5644fdea-\\' ) ); - } - -} diff --git a/tests/phpunit/includes/shell/CommandFactoryTest.php b/tests/phpunit/includes/shell/CommandFactoryTest.php deleted file mode 100644 index b031431af7..0000000000 --- a/tests/phpunit/includes/shell/CommandFactoryTest.php +++ /dev/null @@ -1,50 +0,0 @@ - 1000, - 'memory' => 1000, - 'time' => 30, - 'walltime' => 40, - ]; - - $factory = new CommandFactory( $limits, $cgroup, false ); - $factory->setLogger( $logger ); - $factory->logStderr(); - $command = $factory->create(); - $this->assertInstanceOf( Command::class, $command ); - - $wrapper = TestingAccessWrapper::newFromObject( $command ); - $this->assertSame( $logger, $wrapper->logger ); - $this->assertSame( $cgroup, $wrapper->cgroup ); - $this->assertSame( $limits, $wrapper->limits ); - $this->assertTrue( $wrapper->doLogStderr ); - } - - /** - * @covers MediaWiki\Shell\CommandFactory::create - */ - public function testFirejailCreate() { - $factory = new CommandFactory( [], false, 'firejail' ); - $factory->setLogger( new NullLogger() ); - $this->assertInstanceOf( FirejailCommand::class, $factory->create() ); - } -} diff --git a/tests/phpunit/includes/shell/CommandTest.php b/tests/phpunit/includes/shell/CommandTest.php index 2e03163885..c5e8e897ab 100644 --- a/tests/phpunit/includes/shell/CommandTest.php +++ b/tests/phpunit/includes/shell/CommandTest.php @@ -119,15 +119,18 @@ class CommandTest extends PHPUnit\Framework\TestCase { } public function testT69870() { - $commandLine = wfIsWindows() - // 333 = 331 + CRLF - ? ( 'for /l %i in (1, 1, 1001) do @echo ' . str_repeat( '*', 331 ) ) - : 'printf "%-333333s" "*"'; + if ( wfIsWindows() ) { + // T209159: Anonymous pipe under Windows does not support asynchronous read and write, + // and the default buffer is too small (~4K), it is easy to be blocked. + $this->markTestSkipped( + 'T209159: Anonymous pipe under Windows cannot withstand such a large amount of data' + ); + } // Test several times because it involves a race condition that may randomly succeed or fail for ( $i = 0; $i < 10; $i++ ) { $command = new Command(); - $output = $command->unsafeParams( $commandLine ) + $output = $command->unsafeParams( 'printf "%-333333s" "*"' ) ->execute() ->getStdout(); $this->assertEquals( 333333, strlen( $output ) ); diff --git a/tests/phpunit/includes/shell/FirejailCommandTest.php b/tests/phpunit/includes/shell/FirejailCommandTest.php deleted file mode 100644 index 681c3dcda0..0000000000 --- a/tests/phpunit/includes/shell/FirejailCommandTest.php +++ /dev/null @@ -1,85 +0,0 @@ - - * - * 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. - * - */ - -use MediaWiki\Shell\FirejailCommand; -use MediaWiki\Shell\Shell; -use Wikimedia\TestingAccessWrapper; - -class FirejailCommandTest extends PHPUnit\Framework\TestCase { - - use MediaWikiCoversValidator; - - public function provideBuildFinalCommand() { - global $IP; - // phpcs:ignore Generic.Files.LineLength - $env = "'MW_INCLUDE_STDERR=;MW_CPU_LIMIT=180; MW_CGROUP='\'''\''; MW_MEM_LIMIT=307200; MW_FILE_SIZE_LIMIT=102400; MW_WALL_CLOCK_LIMIT=180; MW_USE_LOG_PIPE=yes'"; - $limit = "/bin/bash '$IP/includes/shell/limit.sh'"; - $profile = "--profile=$IP/includes/shell/firejail.profile"; - $blacklist = '--blacklist=' . realpath( MW_CONFIG_FILE ); - $default = "$blacklist --noroot --seccomp --private-dev"; - return [ - [ - 'No restrictions', - 'ls', 0, "$limit ''\''ls'\''' $env" - ], - [ - 'default restriction', - 'ls', Shell::RESTRICT_DEFAULT, - "$limit 'firejail --quiet $profile $default -- '\''ls'\''' $env" - ], - [ - 'no network', - 'ls', Shell::NO_NETWORK, - "$limit 'firejail --quiet $profile --net=none -- '\''ls'\''' $env" - ], - [ - 'default restriction & no network', - 'ls', Shell::RESTRICT_DEFAULT | Shell::NO_NETWORK, - "$limit 'firejail --quiet $profile $default --net=none -- '\''ls'\''' $env" - ], - [ - 'seccomp', - 'ls', Shell::SECCOMP, - "$limit 'firejail --quiet $profile --seccomp -- '\''ls'\''' $env" - ], - [ - 'seccomp & no execve', - 'ls', Shell::SECCOMP | Shell::NO_EXECVE, - "$limit 'firejail --quiet $profile --shell=none --seccomp=execve -- '\''ls'\''' $env" - ], - ]; - } - - /** - * @covers \MediaWiki\Shell\FirejailCommand::buildFinalCommand() - * @dataProvider provideBuildFinalCommand - */ - public function testBuildFinalCommand( $desc, $params, $flags, $expected ) { - $command = new FirejailCommand( 'firejail' ); - $command - ->params( $params ) - ->restrict( $flags ); - $wrapper = TestingAccessWrapper::newFromObject( $command ); - $output = $wrapper->buildFinalCommand( $wrapper->command ); - $this->assertEquals( $expected, $output[0], $desc ); - } - -} diff --git a/tests/phpunit/includes/site/CachingSiteStoreTest.php b/tests/phpunit/includes/site/CachingSiteStoreTest.php index f04d35ca02..df12eba765 100644 --- a/tests/phpunit/includes/site/CachingSiteStoreTest.php +++ b/tests/phpunit/includes/site/CachingSiteStoreTest.php @@ -27,7 +27,7 @@ * * @author Jeroen De Dauw < jeroendedauw@gmail.com > */ -class CachingSiteStoreTest extends MediaWikiTestCase { +class CachingSiteStoreTest extends \MediaWikiIntegrationTestCase { /** * @covers CachingSiteStore::getSites diff --git a/tests/phpunit/includes/site/HashSiteStoreTest.php b/tests/phpunit/includes/site/HashSiteStoreTest.php index 6269fd39dc..3912504038 100644 --- a/tests/phpunit/includes/site/HashSiteStoreTest.php +++ b/tests/phpunit/includes/site/HashSiteStoreTest.php @@ -24,7 +24,7 @@ * * @author Katie Filbert < aude.wiki@gmail.com > */ -class HashSiteStoreTest extends MediaWikiTestCase { +class HashSiteStoreTest extends \MediaWikiIntegrationTestCase { /** * @covers HashSiteStore::getSites diff --git a/tests/phpunit/includes/site/MediaWikiPageNameNormalizerTest.php b/tests/phpunit/includes/site/MediaWikiPageNameNormalizerTest.php deleted file mode 100644 index 15894a3d9a..0000000000 --- a/tests/phpunit/includes/site/MediaWikiPageNameNormalizerTest.php +++ /dev/null @@ -1,116 +0,0 @@ -assertSame( - $expected, - $normalizer->normalizePageName( $pageName, 'https://www.wikidata.org/w/api.php' ) - ); - } - - public function normalizePageTitleProvider() { - // Response are taken from wikidata and kkwiki using the following API request - // api.php?action=query&prop=info&redirects=1&converttitles=1&format=json&titles=… - return [ - 'universe (Q1)' => [ - 'Q1', - 'Q1', - '{"batchcomplete":"","query":{"pages":{"129":{"pageid":129,"ns":0,' - . '"title":"Q1","contentmodel":"wikibase-item","pagelanguage":"en",' - . '"pagelanguagehtmlcode":"en","pagelanguagedir":"ltr",' - . '"touched":"2016-06-23T05:11:21Z","lastrevid":350004448,"length":58001}}}}' - ], - 'Q404 redirects to Q395' => [ - 'Q395', - 'Q404', - '{"batchcomplete":"","query":{"redirects":[{"from":"Q404","to":"Q395"}],"pages"' - . ':{"601":{"pageid":601,"ns":0,"title":"Q395","contentmodel":"wikibase-item",' - . '"pagelanguage":"en","pagelanguagehtmlcode":"en","pagelanguagedir":"ltr",' - . '"touched":"2016-06-23T08:00:20Z","lastrevid":350021914,"length":60108}}}}' - ], - 'D converted to Д (Latin to Cyrillic) (taken from kkwiki)' => [ - 'Д', - 'D', - '{"batchcomplete":"","query":{"converted":[{"from":"D","to":"\u0414"}],' - . '"pages":{"510541":{"pageid":510541,"ns":0,"title":"\u0414",' - . '"contentmodel":"wikitext","pagelanguage":"kk","pagelanguagehtmlcode":"kk",' - . '"pagelanguagedir":"ltr","touched":"2015-11-22T09:16:18Z",' - . '"lastrevid":2373618,"length":3501}}}}' - ], - 'there is no Q0' => [ - false, - 'Q0', - '{"batchcomplete":"","query":{"pages":{"-1":{"ns":0,"title":"Q0",' - . '"missing":"","contentmodel":"wikibase-item","pagelanguage":"en",' - . '"pagelanguagehtmlcode":"en","pagelanguagedir":"ltr"}}}}' - ], - 'invalid title' => [ - false, - '{{', - '{"batchcomplete":"","query":{"pages":{"-1":{"title":"{{",' - . '"invalidreason":"The requested page title contains invalid ' - . 'characters: \"{\".","invalid":""}}}}' - ], - 'error on get' => [ false, 'ABC', false ] - ]; - } - -} - -/** - * @private - * @see Http - */ -class MediaWikiPageNameNormalizerTestMockHttp extends Http { - - /** - * @var mixed - */ - public static $response; - - public static function get( $url, array $options = [], $caller = __METHOD__ ) { - PHPUnit_Framework_Assert::assertInternalType( 'string', $url ); - PHPUnit_Framework_Assert::assertInternalType( 'string', $caller ); - - return self::$response; - } -} diff --git a/tests/phpunit/includes/site/SiteExporterTest.php b/tests/phpunit/includes/site/SiteExporterTest.php deleted file mode 100644 index 97a43f8d5b..0000000000 --- a/tests/phpunit/includes/site/SiteExporterTest.php +++ /dev/null @@ -1,148 +0,0 @@ -setExpectedException( InvalidArgumentException::class ); - - new SiteExporter( 'Foo' ); - } - - public function testExportSites() { - $foo = Site::newForType( Site::TYPE_UNKNOWN ); - $foo->setGlobalId( 'Foo' ); - - $acme = Site::newForType( Site::TYPE_UNKNOWN ); - $acme->setGlobalId( 'acme.com' ); - $acme->setGroup( 'Test' ); - $acme->addLocalId( Site::ID_INTERWIKI, 'acme' ); - $acme->setPath( Site::PATH_LINK, 'http://acme.com/' ); - - $tmp = tmpfile(); - $exporter = new SiteExporter( $tmp ); - - $exporter->exportSites( [ $foo, $acme ] ); - - fseek( $tmp, 0 ); - $xml = fread( $tmp, 16 * 1024 ); - - $this->assertContains( 'assertContains( '', $xml ); - $this->assertContains( 'Foo', $xml ); - $this->assertContains( '', $xml ); - $this->assertContains( 'acme.com', $xml ); - $this->assertContains( 'Test', $xml ); - $this->assertContains( 'acme', $xml ); - $this->assertContains( 'http://acme.com/', $xml ); - $this->assertContains( '', $xml ); - - // NOTE: HHVM (at least on wmf Jenkins) doesn't like file URLs. - $xsdFile = __DIR__ . '/../../../../docs/sitelist-1.0.xsd'; - $xsdData = file_get_contents( $xsdFile ); - - $document = new DOMDocument(); - $document->loadXML( $xml, LIBXML_NONET ); - $document->schemaValidateSource( $xsdData ); - } - - private function newSiteStore( SiteList $sites ) { - $store = $this->getMockBuilder( SiteStore::class )->getMock(); - - $store->expects( $this->once() ) - ->method( 'saveSites' ) - ->will( $this->returnCallback( function ( $moreSites ) use ( $sites ) { - foreach ( $moreSites as $site ) { - $sites->setSite( $site ); - } - } ) ); - - $store->expects( $this->any() ) - ->method( 'getSites' ) - ->will( $this->returnValue( new SiteList() ) ); - - return $store; - } - - public function provideRoundTrip() { - $foo = Site::newForType( Site::TYPE_UNKNOWN ); - $foo->setGlobalId( 'Foo' ); - - $acme = Site::newForType( Site::TYPE_UNKNOWN ); - $acme->setGlobalId( 'acme.com' ); - $acme->setGroup( 'Test' ); - $acme->addLocalId( Site::ID_INTERWIKI, 'acme' ); - $acme->setPath( Site::PATH_LINK, 'http://acme.com/' ); - - $dewiki = Site::newForType( Site::TYPE_MEDIAWIKI ); - $dewiki->setGlobalId( 'dewiki' ); - $dewiki->setGroup( 'wikipedia' ); - $dewiki->setForward( true ); - $dewiki->addLocalId( Site::ID_INTERWIKI, 'wikipedia' ); - $dewiki->addLocalId( Site::ID_EQUIVALENT, 'de' ); - $dewiki->setPath( Site::PATH_LINK, 'http://de.wikipedia.org/w/' ); - $dewiki->setPath( MediaWikiSite::PATH_PAGE, 'http://de.wikipedia.org/wiki/' ); - $dewiki->setSource( 'meta.wikimedia.org' ); - - return [ - 'empty' => [ - new SiteList() - ], - - 'some' => [ - new SiteList( [ $foo, $acme, $dewiki ] ), - ], - ]; - } - - /** - * @dataProvider provideRoundTrip() - */ - public function testRoundTrip( SiteList $sites ) { - $tmp = tmpfile(); - $exporter = new SiteExporter( $tmp ); - - $exporter->exportSites( $sites ); - - fseek( $tmp, 0 ); - $xml = fread( $tmp, 16 * 1024 ); - - $actualSites = new SiteList(); - $store = $this->newSiteStore( $actualSites ); - - $importer = new SiteImporter( $store ); - $importer->importFromXML( $xml ); - - $this->assertEquals( $sites, $actualSites ); - } - -} diff --git a/tests/phpunit/includes/site/SiteImporterTest.php b/tests/phpunit/includes/site/SiteImporterTest.php deleted file mode 100644 index dbdbd6fcc2..0000000000 --- a/tests/phpunit/includes/site/SiteImporterTest.php +++ /dev/null @@ -1,200 +0,0 @@ -getMockBuilder( SiteStore::class )->getMock(); - - $store->expects( $this->once() ) - ->method( 'saveSites' ) - ->will( $this->returnCallback( function ( $sites ) use ( $expectedSites ) { - $this->assertSitesEqual( $expectedSites, $sites ); - } ) ); - - $store->expects( $this->any() ) - ->method( 'getSites' ) - ->will( $this->returnValue( new SiteList() ) ); - - $errorHandler = $this->getMockBuilder( Psr\Log\LoggerInterface::class )->getMock(); - $errorHandler->expects( $this->exactly( $errorCount ) ) - ->method( 'error' ); - - $importer = new SiteImporter( $store ); - $importer->setExceptionCallback( [ $errorHandler, 'error' ] ); - - return $importer; - } - - public function assertSitesEqual( $expected, $actual, $message = '' ) { - $this->assertEquals( - $this->getSerializedSiteList( $expected ), - $this->getSerializedSiteList( $actual ), - $message - ); - } - - public function provideImportFromXML() { - $foo = Site::newForType( Site::TYPE_UNKNOWN ); - $foo->setGlobalId( 'Foo' ); - - $acme = Site::newForType( Site::TYPE_UNKNOWN ); - $acme->setGlobalId( 'acme.com' ); - $acme->setGroup( 'Test' ); - $acme->addLocalId( Site::ID_INTERWIKI, 'acme' ); - $acme->setPath( Site::PATH_LINK, 'http://acme.com/' ); - - $dewiki = Site::newForType( Site::TYPE_MEDIAWIKI ); - $dewiki->setGlobalId( 'dewiki' ); - $dewiki->setGroup( 'wikipedia' ); - $dewiki->setForward( true ); - $dewiki->addLocalId( Site::ID_INTERWIKI, 'wikipedia' ); - $dewiki->addLocalId( Site::ID_EQUIVALENT, 'de' ); - $dewiki->setPath( Site::PATH_LINK, 'http://de.wikipedia.org/w/' ); - $dewiki->setPath( MediaWikiSite::PATH_PAGE, 'http://de.wikipedia.org/wiki/' ); - $dewiki->setSource( 'meta.wikimedia.org' ); - - return [ - 'empty' => [ - '', - [], - ], - 'no sites' => [ - 'FooBla', - [], - ], - 'minimal' => [ - '' . - 'Foo' . - '', - [ $foo ], - ], - 'full' => [ - '' . - 'Foo' . - '' . - 'acme.com' . - 'acme' . - 'Test' . - 'http://acme.com/' . - '' . - '' . - 'meta.wikimedia.org' . - 'dewiki' . - 'wikipedia' . - 'de' . - 'wikipedia' . - '' . - 'http://de.wikipedia.org/w/' . - 'http://de.wikipedia.org/wiki/' . - '' . - '', - [ $foo, $acme, $dewiki ], - ], - 'skip' => [ - '' . - 'Foo' . - 'Foo' . - '' . - 'acme.com' . - 'acme' . - 'boop!' . - 'Test' . - 'http://acme.com/' . - '' . - '', - [ $foo, $acme ], - 1 - ], - ]; - } - - /** - * @dataProvider provideImportFromXML - */ - public function testImportFromXML( $xml, array $expectedSites, $errorCount = 0 ) { - $importer = $this->newSiteImporter( $expectedSites, $errorCount ); - $importer->importFromXML( $xml ); - } - - public function testImportFromXML_malformed() { - $this->setExpectedException( Exception::class ); - - $store = $this->getMockBuilder( SiteStore::class )->getMock(); - $importer = new SiteImporter( $store ); - $importer->importFromXML( 'THIS IS NOT XML' ); - } - - public function testImportFromFile() { - $foo = Site::newForType( Site::TYPE_UNKNOWN ); - $foo->setGlobalId( 'Foo' ); - - $acme = Site::newForType( Site::TYPE_UNKNOWN ); - $acme->setGlobalId( 'acme.com' ); - $acme->setGroup( 'Test' ); - $acme->addLocalId( Site::ID_INTERWIKI, 'acme' ); - $acme->setPath( Site::PATH_LINK, 'http://acme.com/' ); - - $dewiki = Site::newForType( Site::TYPE_MEDIAWIKI ); - $dewiki->setGlobalId( 'dewiki' ); - $dewiki->setGroup( 'wikipedia' ); - $dewiki->setForward( true ); - $dewiki->addLocalId( Site::ID_INTERWIKI, 'wikipedia' ); - $dewiki->addLocalId( Site::ID_EQUIVALENT, 'de' ); - $dewiki->setPath( Site::PATH_LINK, 'http://de.wikipedia.org/w/' ); - $dewiki->setPath( MediaWikiSite::PATH_PAGE, 'http://de.wikipedia.org/wiki/' ); - $dewiki->setSource( 'meta.wikimedia.org' ); - - $importer = $this->newSiteImporter( [ $foo, $acme, $dewiki ], 0 ); - - $file = __DIR__ . '/SiteImporterTest.xml'; - $importer->importFromFile( $file ); - } - - /** - * @param Site[] $sites - * - * @return array[] - */ - private function getSerializedSiteList( $sites ) { - $serialized = []; - - foreach ( $sites as $site ) { - $key = $site->getGlobalId(); - $data = unserialize( $site->serialize() ); - - $serialized[$key] = $data; - } - - return $serialized; - } -} diff --git a/tests/phpunit/includes/site/SiteImporterTest.xml b/tests/phpunit/includes/site/SiteImporterTest.xml deleted file mode 100644 index 720b1faf1a..0000000000 --- a/tests/phpunit/includes/site/SiteImporterTest.xml +++ /dev/null @@ -1,19 +0,0 @@ - - Foo - - acme.com - acme - Test - http://acme.com/ - - - meta.wikimedia.org - dewiki - wikipedia - de - wikipedia - - http://de.wikipedia.org/w/ - http://de.wikipedia.org/wiki/ - - diff --git a/tests/phpunit/includes/skins/SkinFactoryTest.php b/tests/phpunit/includes/skins/SkinFactoryTest.php deleted file mode 100644 index 4289fd9188..0000000000 --- a/tests/phpunit/includes/skins/SkinFactoryTest.php +++ /dev/null @@ -1,82 +0,0 @@ -register( 'fallback', 'Fallback', function () { - return new SkinFallback(); - } ); - $this->assertTrue( true ); // No exception thrown - $this->setExpectedException( InvalidArgumentException::class ); - $factory->register( 'invalid', 'Invalid', 'Invalid callback' ); - } - - /** - * @covers SkinFactory::makeSkin - */ - public function testMakeSkinWithNoBuilders() { - $factory = new SkinFactory(); - $this->setExpectedException( SkinException::class ); - $factory->makeSkin( 'nobuilderregistered' ); - } - - /** - * @covers SkinFactory::makeSkin - */ - public function testMakeSkinWithInvalidCallback() { - $factory = new SkinFactory(); - $factory->register( 'unittest', 'Unittest', function () { - return true; // Not a Skin object - } ); - $this->setExpectedException( UnexpectedValueException::class ); - $factory->makeSkin( 'unittest' ); - } - - /** - * @covers SkinFactory::makeSkin - */ - public function testMakeSkinWithValidCallback() { - $factory = new SkinFactory(); - $factory->register( 'testfallback', 'TestFallback', function () { - return new SkinFallback(); - } ); - - $skin = $factory->makeSkin( 'testfallback' ); - $this->assertInstanceOf( Skin::class, $skin ); - $this->assertInstanceOf( SkinFallback::class, $skin ); - $this->assertEquals( 'fallback', $skin->getSkinName() ); - } - - /** - * @covers Skin::__construct - * @covers Skin::getSkinName - */ - public function testGetSkinName() { - $skin = new SkinFallback(); - $this->assertEquals( 'fallback', $skin->getSkinName(), 'Default' ); - $skin = new SkinFallback( 'testname' ); - $this->assertEquals( 'testname', $skin->getSkinName(), 'Constructor argument' ); - } - - /** - * @covers SkinFactory::getSkinNames - */ - public function testGetSkinNames() { - $factory = new SkinFactory(); - // A fake callback we can use that will never be called - $callback = function () { - // NOP - }; - $factory->register( 'skin1', 'Skin1', $callback ); - $factory->register( 'skin2', 'Skin2', $callback ); - $names = $factory->getSkinNames(); - $this->assertArrayHasKey( 'skin1', $names ); - $this->assertArrayHasKey( 'skin2', $names ); - $this->assertEquals( 'Skin1', $names['skin1'] ); - $this->assertEquals( 'Skin2', $names['skin2'] ); - } -} diff --git a/tests/phpunit/includes/specials/SpecialGoToInterwikiTest.php b/tests/phpunit/includes/specials/SpecialGoToInterwikiTest.php new file mode 100644 index 0000000000..05ec710460 --- /dev/null +++ b/tests/phpunit/includes/specials/SpecialGoToInterwikiTest.php @@ -0,0 +1,81 @@ +setService( 'InterwikiLookup', new InterwikiLookupAdapter( + new HashSiteStore(), // won't be used + [ + 'local' => new Interwiki( 'local', 'https://local.example.com/$1', + 'https://local.example.com/api.php', 'unittest_localwiki', 1 ), + 'nonlocal' => new Interwiki( 'nonlocal', 'https://nonlocal.example.com/$1', + 'https://nonlocal.example.com/api.php', 'unittest_nonlocalwiki', 0 ), + ] + ) ); + MediaWikiServices::getInstance()->resetServiceForTesting( 'TitleFormatter' ); + MediaWikiServices::getInstance()->resetServiceForTesting( 'TitleParser' ); + MediaWikiServices::getInstance()->resetServiceForTesting( '_MediaWikiTitleCodec' ); + + // sanity check + $this->assertTrue( !Title::newFromText( 'Foo' )->isExternal() ); + $this->assertTrue( Title::newFromText( 'local:Foo' )->isExternal() ); + $this->assertTrue( Title::newFromText( 'nonlocal:Foo' )->isExternal() ); + $this->assertTrue( Title::newFromText( 'local:Foo' )->isLocal() ); + $this->assertTrue( !Title::newFromText( 'nonlocal:Foo' )->isLocal() ); + + $goToInterwiki = MediaWikiServices::getInstance()->getSpecialPageFactory() + ->getPage( 'GoToInterwiki' ); + + RequestContext::resetMain(); + $context = new DerivativeContext( RequestContext::getMain() ); + $goToInterwiki->setContext( $context ); + $goToInterwiki->execute( 'Foo' ); + $this->assertSame( Title::newFromText( 'Foo' )->getFullURL(), + $context->getOutput()->getRedirect() ); + + RequestContext::resetMain(); + $context = new DerivativeContext( RequestContext::getMain() ); + $goToInterwiki->setContext( $context ); + $goToInterwiki->execute( 'local:Foo' ); + $this->assertSame( Title::newFromText( 'local:Foo' )->getFullURL(), + $context->getOutput()->getRedirect() ); + + RequestContext::resetMain(); + $context = new DerivativeContext( RequestContext::getMain() ); + $goToInterwiki->setContext( $context ); + $goToInterwiki->execute( 'nonlocal:Foo' ); + $this->assertSame( '', $context->getOutput()->getRedirect() ); + $this->assertContains( Title::newFromText( 'nonlocal:Foo' )->getFullURL(), + $context->getOutput()->getHTML() ); + + RequestContext::resetMain(); + $context = new DerivativeContext( RequestContext::getMain() ); + $goToInterwiki->setContext( $context ); + $goToInterwiki->execute( 'force/Foo' ); + $this->assertSame( Title::newFromText( 'Foo' )->getFullURL(), + $context->getOutput()->getRedirect() ); + + RequestContext::resetMain(); + $context = new DerivativeContext( RequestContext::getMain() ); + $goToInterwiki->setContext( $context ); + $goToInterwiki->execute( 'force/local:Foo' ); + $this->assertSame( '', $context->getOutput()->getRedirect() ); + $this->assertContains( Title::newFromText( 'local:Foo' )->getFullURL(), + $context->getOutput()->getHTML() ); + + RequestContext::resetMain(); + $context = new DerivativeContext( RequestContext::getMain() ); + $goToInterwiki->setContext( $context ); + $goToInterwiki->execute( 'force/nonlocal:Foo' ); + $this->assertSame( '', $context->getOutput()->getRedirect() ); + $this->assertContains( Title::newFromText( 'nonlocal:Foo' )->getFullURL(), + $context->getOutput()->getHTML() ); + } + +} diff --git a/tests/phpunit/includes/specials/SpecialMuteTest.php b/tests/phpunit/includes/specials/SpecialMuteTest.php new file mode 100644 index 0000000000..a57745be53 --- /dev/null +++ b/tests/phpunit/includes/specials/SpecialMuteTest.php @@ -0,0 +1,115 @@ +setMwGlobals( [ + 'wgEnableUserEmailBlacklist' => true + ] ); + } + + /** + * @inheritDoc + */ + protected function newSpecialPage() { + return new SpecialMute(); + } + + /** + * @covers SpecialMute::execute + * @expectedExceptionMessage username requested could not be found + * @expectedException ErrorPageError + */ + public function testInvalidTarget() { + $user = $this->getTestUser()->getUser(); + $this->executeSpecialPage( + 'InvalidUser', null, 'qqx', $user + ); + } + + /** + * @covers SpecialMute::execute + * @expectedExceptionMessage Mute features are unavailable + * @expectedException ErrorPageError + */ + public function testEmailBlacklistNotEnabled() { + $this->setTemporaryHook( + 'SpecialMuteModifyFormFields', + null + ); + + $this->setMwGlobals( [ + 'wgEnableUserEmailBlacklist' => false + ] ); + + $user = $this->getTestUser()->getUser(); + $this->executeSpecialPage( + $user->getName(), null, 'qqx', $user + ); + } + + /** + * @covers SpecialMute::execute + * @expectedException UserNotLoggedIn + */ + public function testUserNotLoggedIn() { + $this->executeSpecialPage( 'TestUser' ); + } + + /** + * @covers SpecialMute::execute + */ + public function testMuteAddsUserToEmailBlacklist() { + $this->setMwGlobals( [ + 'wgCentralIdLookupProvider' => 'local', + ] ); + + $targetUser = $this->getTestUser()->getUser(); + + $loggedInUser = $this->getMutableTestUser()->getUser(); + $loggedInUser->setOption( 'email-blacklist', "999" ); + $loggedInUser->confirmEmail(); + $loggedInUser->saveSettings(); + + $fauxRequest = new FauxRequest( [ 'wpemail-blacklist' => true ], true ); + list( $html, ) = $this->executeSpecialPage( + $targetUser->getName(), $fauxRequest, 'qqx', $loggedInUser + ); + + $this->assertContains( 'specialmute-success', $html ); + $this->assertEquals( + "999\n" . $targetUser->getId(), + $loggedInUser->getOption( 'email-blacklist' ) + ); + } + + /** + * @covers SpecialMute::execute + */ + public function testUnmuteRemovesUserFromEmailBlacklist() { + $this->setMwGlobals( [ + 'wgCentralIdLookupProvider' => 'local', + ] ); + + $targetUser = $this->getTestUser()->getUser(); + + $loggedInUser = $this->getMutableTestUser()->getUser(); + $loggedInUser->setOption( 'email-blacklist', "999\n" . $targetUser->getId() ); + $loggedInUser->confirmEmail(); + $loggedInUser->saveSettings(); + + $fauxRequest = new FauxRequest( [ 'wpemail-blacklist' => false ], true ); + list( $html, ) = $this->executeSpecialPage( + $targetUser->getName(), $fauxRequest, 'qqx', $loggedInUser + ); + + $this->assertContains( 'specialmute-success', $html ); + $this->assertEquals( "999", $loggedInUser->getOption( 'email-blacklist' ) ); + } +} diff --git a/tests/phpunit/includes/specials/SpecialSearchTest.php b/tests/phpunit/includes/specials/SpecialSearchTest.php index 4f4fa259a0..eeb4b003c6 100644 --- a/tests/phpunit/includes/specials/SpecialSearchTest.php +++ b/tests/phpunit/includes/specials/SpecialSearchTest.php @@ -11,13 +11,34 @@ use MediaWiki\MediaWikiServices; */ class SpecialSearchTest extends MediaWikiTestCase { + /** + * @covers SpecialSearch::load + * @covers SpecialSearch::showResults + */ + public function testValidateSortOrder() { + $ctx = new RequestContext(); + $ctx->setRequest( new FauxRequest( [ + 'search' => 'foo', + 'fulltext' => 1, + 'sort' => 'invalid', + ] ) ); + $sp = Title::makeTitle( NS_SPECIAL, 'Search' ); + MediaWikiServices::getInstance() + ->getSpecialPageFactory() + ->executePath( $sp, $ctx ); + $html = $ctx->getOutput()->getHTML(); + $this->assertRegExp( '/class="warningbox"/', $html, 'must contain warnings' ); + $this->assertRegExp( '/Sort order of invalid is unrecognized/', + $html, 'must tell user sort order is invalid' ); + } + /** * @covers SpecialSearch::load * @dataProvider provideSearchOptionsTests * @param array $requested Request parameters. For example: - * array( 'ns5' => true, 'ns6' => true). Null to use default options. + * [ 'ns5' => true, 'ns6' => true ]. Null to use default options. * @param array $userOptions User options to test with. For example: - * array('searchNs5' => 1 );. Null to use default options. + * [ 'searchNs5' => 1 ];. Null to use default options. * @param string $expectedProfile An expected search profile name * @param array $expectedNS Expected namespaces * @param string $message diff --git a/tests/phpunit/includes/tidy/RemexDriverTest.php b/tests/phpunit/includes/tidy/RemexDriverTest.php deleted file mode 100644 index 5ad8416b81..0000000000 --- a/tests/phpunit/includes/tidy/RemexDriverTest.php +++ /dev/null @@ -1,326 +0,0 @@ -x

    " - ], - [ - 'No p-wrap of blank node', - " ", - " " - ], - [ - 'p-wrap terminated by div', - "x
    ", - "

    x

    " - ], - [ - 'p-wrap not terminated by span', - "x", - "

    x

    " - ], - [ - 'An element is non-blank and so gets p-wrapped', - "", - "

    " - ], - [ - 'The blank flag is set after a block-level element', - "
    ", - "
    " - ], - [ - 'Blank detection between two block-level elements', - "
    ", - "
    " - ], - [ - 'But p-wrapping of non-blank content works after an element', - "
    x", - "

    x

    " - ], - [ - 'p-wrapping between two block-level elements', - "
    x
    ", - "

    x

    " - ], - [ - 'p-wrap inside blockquote', - "
    x
    ", - "

    x

    " - ], - [ - 'A comment is blank for p-wrapping purposes', - "", - "" - ], - [ - 'A comment is blank even when a p-wrap was opened by a text node', - " ", - " " - ], - [ - 'A comment does not open a p-wrap', - "x", - "

    x

    " - ], - [ - 'A comment does not close a p-wrap', - "x", - "

    x

    " - ], - [ - 'Empty li', - "
    ", - "
    " - ], - [ - 'li with element', - "
    ", - "
    " - ], - [ - 'li with text', - "
    • x
    ", - "
    • x
    " - ], - [ - 'Empty tr', - "
    ", - "
    " - ], - [ - 'Empty p', - "

    \n

    ", - "

    \n

    " - ], - [ - 'No p-wrapping of an inline element which contains a block element (T150317)', - "
    x
    ", - "
    x
    " - ], - [ - 'p-wrapping of an inline element which contains an inline element', - "x", - "

    x

    " - ], - [ - 'p-wrapping is enabled in a blockquote in an inline element', - "
    x
    ", - "

    x

    " - ], - [ - 'All bare text should be p-wrapped even when surrounded by block tags', - "
    x
    y
    z", - "

    x

    y

    z

    " - ], - [ - 'Split tag stack 1', - "x
    y
    z
    ", - "

    x

    y

    z

    " - ], - [ - 'Split tag stack 2', - "
    y
    z
    ", - "
    y

    z

    " - ], - [ - 'Split tag stack 3', - "x
    y
    ", - "

    x

    y
    " - ], - [ - 'Split tag stack 4 (modified to use splittable tag)', - "abc
    d
    e
    ", - "

    abc

    d

    e

    " - ], - [ - "Split tag stack regression check 1", - "x
    y
    ", - "

    x

    y
    " - ], - [ - "Split tag stack regression check 2 (modified to use splittable tag)", - "a
    d
    e
    ", - "

    a

    d

    e

    " - ], - // Simple tests from pwrap.js - [ - 'Simple pwrap test 1', - 'a', - '

    a

    ' - ], - [ - ' is not a splittable tag, but gets p-wrapped in simple wrapping scenarios', - 'a', - '

    a

    ' - ], - [ - 'Simple pwrap test 3', - 'x
    a
    b
    y', - '

    x

    a
    b

    y

    ' - ], - [ - 'Simple pwrap test 4', - 'x
    a
    b
    y', - '

    x

    a
    b

    y

    ' - ], - // Complex tests from pwrap.js - [ - 'Complex pwrap test 1', - 'x
    a
    y
    ', - '

    x

    a

    y

    ' - ], - [ - 'Complex pwrap test 2', - 'abc
    d
    e
    f', - '

    abc

    d

    ef

    ' - ], - [ - 'Complex pwrap test 3', - 'abc
    d
    e
    ', - '

    abc

    d

    e

    ' - ], - [ - 'Complex pwrap test 4', - 'x
    y
    ', - '

    x

    y
    ' - ], - [ - 'Complex pwrap test 5', - 'a
    d
    e
    ', - '

    a

    d

    e

    ' - ], - // phpcs:disable Generic.Files.LineLength - [ - 'Complex pwrap test 6', - 'a
    b
    cd
    e
    f
    g
    ', - // PHP 5 does not allow concatenation in initialisation of a class static variable - '

    a

    b

    cd

    e

    fg

    ' - ], - // phpcs:enable - /* FIXME the second causes a stack split which clones the even - * though no

    is actually generated - [ - 'Complex pwrap test 7', - '

    x
    y
    z
    ', - '
    x
    y
    z
    ' - ], - */ - // New local tests - [ - 'Blank text node after block end', - 'x
    y
    z
    ', - '

    x

    y

    z

    ' - ], - [ - 'Text node fostering (FIXME: wrap missing)', - 'x
    ', - 'x
    ' - ], - [ - 'Blockquote fostering', - '
    x
    ', - '

    x

    ' - ], - [ - 'Block element fostering', - '
    x', - '
    x
    ' - ], - [ - 'Formatting element fostering (FIXME: wrap missing)', - 'x', - 'x
    ' - ], - [ - 'AAA clone of p-wrapped element (FIXME: empty b)', - 'x

    yz

    ', - '

    x

    yz

    ', - ], - [ - 'AAA with fostering (FIXME: wrap missing)', - '1

    23

    ', - '1

    23

    ' - ], - [ - 'AAA causes reparent of p-wrapped text node (T178632)', - '
    x
    ', - '

    x

    ', - ], - [ - 'p-wrap ended by reparenting (T200827)', - '

    ', - '

    ', - ], - [ - 'style tag isn\'t p-wrapped (T186965)', - '', - '', - ], - [ - 'link tag isn\'t p-wrapped (T186965)', - '', - '', - ], - [ - 'style tag doesn\'t split p-wrapping (T208901)', - 'foo bar', - '

    foo bar

    ', - ], - [ - 'link tag doesn\'t split p-wrapping (T208901)', - 'foo bar', - '

    foo bar

    ', - ], - ]; - - public function provider() { - return self::$remexTidyTestData; - } - - /** - * @dataProvider provider - * @covers MediaWiki\Tidy\RemexCompatFormatter - * @covers MediaWiki\Tidy\RemexCompatMunger - * @covers MediaWiki\Tidy\RemexDriver - * @covers MediaWiki\Tidy\RemexMungerData - */ - public function testTidy( $desc, $input, $expected ) { - $r = new MediaWiki\Tidy\RemexDriver( [] ); - $result = $r->tidy( $input ); - $this->assertEquals( $expected, $result, $desc ); - } - - public function html5libProvider() { - $files = json_decode( file_get_contents( __DIR__ . '/html5lib-tests.json' ), true ); - $tests = []; - foreach ( $files as $file => $fileTests ) { - foreach ( $fileTests as $i => $test ) { - $tests[] = [ "$file:$i", $test['data'] ]; - } - } - return $tests; - } - - /** - * This is a quick and dirty test to make sure none of the html5lib tests - * generate exceptions. We don't really know what the expected output is. - * - * @dataProvider html5libProvider - * @coversNothing - */ - public function testHtml5Lib( $desc, $input ) { - $r = new MediaWiki\Tidy\RemexDriver( [] ); - $result = $r->tidy( $input ); - $this->assertTrue( true, $desc ); - } -} diff --git a/tests/phpunit/includes/tidy/html5lib-tests.json b/tests/phpunit/includes/tidy/html5lib-tests.json deleted file mode 100644 index 2b1c3e8cdf..0000000000 --- a/tests/phpunit/includes/tidy/html5lib-tests.json +++ /dev/null @@ -1,80692 +0,0 @@ -{ - "adoption01.dat": [ - { - "data": "

    ", - "errors": [ - "(1,3): expected-doctype-but-got-start-tag", - "(1,10): adoption-agency-1.3" - ], - "document": { - "props": { - "tags": { - "html": true, - "head": true, - "body": true, - "a": true, - "p": true - } - }, - "tree": [ - { - "tag": "html", - "children": [ - { - "tag": "head" - }, - { - "tag": "body", - "children": [ - { - "tag": "a" - }, - { - "tag": "p", - "children": [ - { - "tag": "a" - } - ] - } - ] - } - ] - } - ], - "html": "

    ", - "noQuirksBodyHtml": "

    " - } - }, - { - "data": "1

    23

    ", - "errors": [ - "(1,3): expected-doctype-but-got-start-tag", - "(1,12): adoption-agency-1.3" - ], - "document": { - "props": { - "tags": { - "html": true, - "head": true, - "body": true, - "a": true, - "p": true - } - }, - "tree": [ - { - "tag": "html", - "children": [ - { - "tag": "head" - }, - { - "tag": "body", - "children": [ - { - "tag": "a", - "children": [ - { - "text": "1" - } - ] - }, - { - "tag": "p", - "children": [ - { - "tag": "a", - "children": [ - { - "text": "2" - } - ] - }, - { - "text": "3" - } - ] - } - ] - } - ] - } - ], - "html": "1

    23

    ", - "noQuirksBodyHtml": "1

    23

    " - } - }, - { - "data": "1", - "errors": [ - "(1,3): expected-doctype-but-got-start-tag", - "(1,17): adoption-agency-1.3" - ], - "document": { - "props": { - "tags": { - "html": true, - "head": true, - "body": true, - "a": true, - "button": true - } - }, - "tree": [ - { - "tag": "html", - "children": [ - { - "tag": "head" - }, - { - "tag": "body", - "children": [ - { - "tag": "a", - "children": [ - { - "text": "1" - } - ] - }, - { - "tag": "button", - "children": [ - { - "tag": "a", - "children": [ - { - "text": "2" - } - ] - }, - { - "text": "3" - } - ] - } - ] - } - ] - } - ], - "html": "1", - "noQuirksBodyHtml": "1" - } - }, - { - "data": "123", - "errors": [ - "(1,3): expected-doctype-but-got-start-tag", - "(1,12): adoption-agency-1.3" - ], - "document": { - "props": { - "tags": { - "html": true, - "head": true, - "body": true, - "a": true, - "b": true - } - }, - "tree": [ - { - "tag": "html", - "children": [ - { - "tag": "head" - }, - { - "tag": "body", - "children": [ - { - "tag": "a", - "children": [ - { - "text": "1" - }, - { - "tag": "b", - "children": [ - { - "text": "2" - } - ] - } - ] - }, - { - "tag": "b", - "children": [ - { - "text": "3" - } - ] - } - ] - } - ] - } - ], - "html": "123", - "noQuirksBodyHtml": "123" - } - }, - { - "data": "1
    2
    34
    5
    ", - "errors": [ - "(1,3): expected-doctype-but-got-start-tag", - "(1,20): adoption-agency-1.3", - "(1,20): adoption-agency-1.3" - ], - "document": { - "props": { - "tags": { - "html": true, - "head": true, - "body": true, - "a": true, - "div": true - } - }, - "tree": [ - { - "tag": "html", - "children": [ - { - "tag": "head" - }, - { - "tag": "body", - "children": [ - { - "tag": "a", - "children": [ - { - "text": "1" - } - ] - }, - { - "tag": "div", - "children": [ - { - "tag": "a", - "children": [ - { - "text": "2" - } - ] - }, - { - "tag": "div", - "children": [ - { - "tag": "a", - "children": [ - { - "text": "3" - } - ] - }, - { - "text": "4" - } - ] - }, - { - "text": "5" - } - ] - } - ] - } - ] - } - ], - "html": "1
    2
    34
    5
    ", - "noQuirksBodyHtml": "1
    2
    34
    5
    " - } - }, - { - "data": "1

    23

    ", - "errors": [ - "(1,7): expected-doctype-but-got-start-tag", - "(1,10): unexpected-start-tag-implies-table-voodoo", - "(1,11): unexpected-character-implies-table-voodoo", - "(1,14): unexpected-start-tag-implies-table-voodoo", - "(1,15): unexpected-character-implies-table-voodoo", - "(1,19): unexpected-end-tag-implies-table-voodoo", - "(1,19): adoption-agency-1.3", - "(1,20): unexpected-character-implies-table-voodoo", - "(1,24): unexpected-end-tag-implies-table-voodoo", - "(1,24): eof-in-table" - ], - "document": { - "props": { - "tags": { - "html": true, - "head": true, - "body": true, - "a": true, - "p": true, - "table": true - } - }, - "tree": [ - { - "tag": "html", - "children": [ - { - "tag": "head" - }, - { - "tag": "body", - "children": [ - { - "tag": "a", - "children": [ - { - "text": "1" - } - ] - }, - { - "tag": "p", - "children": [ - { - "tag": "a", - "children": [ - { - "text": "2" - } - ] - }, - { - "text": "3" - } - ] - }, - { - "tag": "table" - } - ] - } - ] - } - ], - "html": "1

    23

    ", - "noQuirksBodyHtml": "1

    23

    " - } - }, - { - "data": "

    ", - "errors": [ - "(1,3): expected-doctype-but-got-start-tag", - "(1,16): adoption-agency-1.3", - "(1,16): expected-closing-tag-but-got-eof" - ], - "document": { - "props": { - "tags": { - "html": true, - "head": true, - "body": true, - "b": true, - "a": true, - "p": true - } - }, - "tree": [ - { - "tag": "html", - "children": [ - { - "tag": "head" - }, - { - "tag": "body", - "children": [ - { - "tag": "b", - "children": [ - { - "tag": "b", - "children": [ - { - "tag": "a" - }, - { - "tag": "p", - "children": [ - { - "tag": "a" - } - ] - } - ] - } - ] - } - ] - } - ] - } - ], - "html": "

    ", - "noQuirksBodyHtml": "

    " - } - }, - { - "data": "

    ", - "errors": [ - "(1,3): expected-doctype-but-got-start-tag", - "(1,16): adoption-agency-1.3", - "(1,16): expected-closing-tag-but-got-eof" - ], - "document": { - "props": { - "tags": { - "html": true, - "head": true, - "body": true, - "b": true, - "a": true, - "p": true - } - }, - "tree": [ - { - "tag": "html", - "children": [ - { - "tag": "head" - }, - { - "tag": "body", - "children": [ - { - "tag": "b", - "children": [ - { - "tag": "a", - "children": [ - { - "tag": "b" - } - ] - }, - { - "tag": "b", - "children": [ - { - "tag": "p", - "children": [ - { - "tag": "a" - } - ] - } - ] - } - ] - } - ] - } - ] - } - ], - "html": "

    ", - "noQuirksBodyHtml": "

    " - } - }, - { - "data": "

    ", - "errors": [ - "(1,3): expected-doctype-but-got-start-tag", - "(1,16): adoption-agency-1.3", - "(1,16): expected-closing-tag-but-got-eof" - ], - "document": { - "props": { - "tags": { - "html": true, - "head": true, - "body": true, - "a": true, - "b": true, - "p": true - } - }, - "tree": [ - { - "tag": "html", - "children": [ - { - "tag": "head" - }, - { - "tag": "body", - "children": [ - { - "tag": "a", - "children": [ - { - "tag": "b", - "children": [ - { - "tag": "b" - } - ] - } - ] - }, - { - "tag": "b", - "children": [ - { - "tag": "b", - "children": [ - { - "tag": "p", - "children": [ - { - "tag": "a" - } - ] - } - ] - } - ] - } - ] - } - ] - } - ], - "html": "

    ", - "noQuirksBodyHtml": "

    " - } - }, - { - "data": "

    123

    45", - "errors": [ - "(1,3): expected-doctype-but-got-start-tag", - "(1,30): unexpected-end-tag", - "(1,35): adoption-agency-1.3" - ], - "document": { - "props": { - "tags": { - "html": true, - "head": true, - "body": true, - "p": true, - "s": true, - "b": true - } - }, - "tree": [ - { - "tag": "html", - "children": [ - { - "tag": "head" - }, - { - "tag": "body", - "children": [ - { - "tag": "p", - "children": [ - { - "text": "1" - }, - { - "tag": "s", - "attrs": [ - { - "name": "id", - "value": "A" - } - ], - "children": [ - { - "text": "2" - }, - { - "tag": "b", - "attrs": [ - { - "name": "id", - "value": "B" - } - ], - "children": [ - { - "text": "3" - } - ] - } - ] - } - ] - }, - { - "tag": "s", - "attrs": [ - { - "name": "id", - "value": "A" - } - ], - "children": [ - { - "tag": "b", - "attrs": [ - { - "name": "id", - "value": "B" - } - ], - "children": [ - { - "text": "4" - } - ] - } - ] - }, - { - "tag": "b", - "attrs": [ - { - "name": "id", - "value": "B" - } - ], - "children": [ - { - "text": "5" - } - ] - } - ] - } - ] - } - ], - "html": "

    123

    45", - "noQuirksBodyHtml": "

    123

    45" - } - }, - { - "data": "13
    2
    ", - "errors": [ - "(1,7): expected-doctype-but-got-start-tag", - "(1,10): unexpected-start-tag-implies-table-voodoo", - "(1,11): unexpected-character-implies-table-voodoo", - "(1,15): unexpected-cell-in-table-body", - "(1,30): unexpected-implied-end-tag-in-table-view" - ], - "document": { - "props": { - "tags": { - "html": true, - "head": true, - "body": true, - "a": true, - "table": true, - "tbody": true, - "tr": true, - "td": true - } - }, - "tree": [ - { - "tag": "html", - "children": [ - { - "tag": "head" - }, - { - "tag": "body", - "children": [ - { - "tag": "a", - "children": [ - { - "text": "1" - } - ] - }, - { - "tag": "a", - "children": [ - { - "text": "3" - } - ] - }, - { - "tag": "table", - "children": [ - { - "tag": "tbody", - "children": [ - { - "tag": "tr", - "children": [ - { - "tag": "td", - "children": [ - { - "text": "2" - } - ] - } - ] - } - ] - } - ] - } - ] - } - ] - } - ], - "html": "13
    2
    ", - "noQuirksBodyHtml": "13
    2
    " - } - }, - { - "data": "AC
    B
    ", - "errors": [ - "(1,7): expected-doctype-but-got-start-tag", - "(1,8): unexpected-character-implies-table-voodoo", - "(1,12): unexpected-cell-in-table-body", - "(1,22): unexpected-character-implies-table-voodoo" - ], - "document": { - "props": { - "tags": { - "html": true, - "head": true, - "body": true, - "table": true, - "tbody": true, - "tr": true, - "td": true - } - }, - "tree": [ - { - "tag": "html", - "children": [ - { - "tag": "head" - }, - { - "tag": "body", - "children": [ - { - "text": "AC" - }, - { - "tag": "table", - "children": [ - { - "tag": "tbody", - "children": [ - { - "tag": "tr", - "children": [ - { - "tag": "td", - "children": [ - { - "text": "B" - } - ] - } - ] - } - ] - } - ] - } - ] - } - ] - } - ], - "html": "AC
    B
    ", - "noQuirksBodyHtml": "AC
    B
    " - } - }, - { - "data": "
    ", - "errors": [ - "(1,3): expected-doctype-but-got-start-tag", - "(1,23): unexpected-end-tag", - "(1,23): adoption-agency-1.3" - ], - "document": { - "props": { - "tags": { - "html": true, - "head": true, - "body": true, - "a": true, - "svg svg": true, - "svg tr": true, - "svg input": true - } - }, - "tree": [ - { - "tag": "html", - "children": [ - { - "tag": "head" - }, - { - "tag": "body", - "children": [ - { - "tag": "a", - "children": [ - { - "tag": "svg", - "ns": "http://www.w3.org/2000/svg", - "children": [ - { - "tag": "tr", - "ns": "http://www.w3.org/2000/svg", - "children": [ - { - "tag": "input", - "ns": "http://www.w3.org/2000/svg" - } - ] - } - ] - } - ] - } - ] - } - ] - } - ], - "html": "
    ", - "noQuirksBodyHtml": "
    " - } - }, - { - "data": "
    ", - "errors": [ - "(1,5): expected-doctype-but-got-start-tag", - "(1,65): adoption-agency-1.3", - "(1,65): adoption-agency-1.3", - "(1,65): adoption-agency-1.3", - "(1,65): adoption-agency-1.3", - "(1,65): adoption-agency-1.3", - "(1,65): adoption-agency-1.3", - "(1,65): adoption-agency-1.3", - "(1,65): adoption-agency-1.3", - "(1,65): expected-closing-tag-but-got-eof" - ], - "document": { - "props": { - "tags": { - "html": true, - "head": true, - "body": true, - "div": true, - "a": true, - "b": true - } - }, - "tree": [ - { - "tag": "html", - "children": [ - { - "tag": "head" - }, - { - "tag": "body", - "children": [ - { - "tag": "div", - "children": [ - { - "tag": "a", - "children": [ - { - "tag": "b" - } - ] - }, - { - "tag": "b", - "children": [ - { - "tag": "div", - "children": [ - { - "tag": "a" - }, - { - "tag": "div", - "children": [ - { - "tag": "a" - }, - { - "tag": "div", - "children": [ - { - "tag": "a" - }, - { - "tag": "div", - "children": [ - { - "tag": "a" - }, - { - "tag": "div", - "children": [ - { - "tag": "a" - }, - { - "tag": "div", - "children": [ - { - "tag": "a" - }, - { - "tag": "div", - "children": [ - { - "tag": "a" - }, - { - "tag": "div", - "children": [ - { - "tag": "a", - "children": [ - { - "tag": "div", - "children": [ - { - "tag": "div" - } - ] - } - ] - } - ] - } - ] - } - ] - } - ] - } - ] - } - ] - } - ] - } - ] - } - ] - } - ] - } - ] - } - ] - } - ], - "html": "
    ", - "noQuirksBodyHtml": "
    " - } - }, - { - "data": "
    ", - "errors": [ - "(1,5): expected-doctype-but-got-start-tag", - "(1,32): adoption-agency-1.3", - "(1,32): expected-closing-tag-but-got-eof" - ], - "document": { - "props": { - "tags": { - "html": true, - "head": true, - "body": true, - "div": true, - "a": true, - "b": true, - "u": true, - "i": true, - "code": true - } - }, - "tree": [ - { - "tag": "html", - "children": [ - { - "tag": "head" - }, - { - "tag": "body", - "children": [ - { - "tag": "div", - "children": [ - { - "tag": "a", - "children": [ - { - "tag": "b", - "children": [ - { - "tag": "u", - "children": [ - { - "tag": "i", - "children": [ - { - "tag": "code" - } - ] - } - ] - } - ] - } - ] - }, - { - "tag": "u", - "children": [ - { - "tag": "i", - "children": [ - { - "tag": "code", - "children": [ - { - "tag": "div", - "children": [ - { - "tag": "a" - } - ] - } - ] - } - ] - } - ] - } - ] - } - ] - } - ] - } - ], - "html": "
    ", - "noQuirksBodyHtml": "
    " - } - }, - { - "data": "xy", - "errors": [ - "(1,3): expected-doctype-but-got-start-tag" - ], - "document": { - "props": { - "tags": { - "html": true, - "head": true, - "body": true, - "b": true - } - }, - "tree": [ - { - "tag": "html", - "children": [ - { - "tag": "head" - }, - { - "tag": "body", - "children": [ - { - "tag": "b", - "children": [ - { - "tag": "b", - "children": [ - { - "tag": "b", - "children": [ - { - "tag": "b", - "children": [ - { - "text": "x" - } - ] - } - ] - } - ] - } - ] - }, - { - "text": "y" - } - ] - } - ] - } - ], - "html": "xy", - "noQuirksBodyHtml": "xy" - } - }, - { - "data": "

    x", - "errors": [ - "(1,3): expected-doctype-but-got-start-tag", - "(1,18): unexpected-end-tag", - "(1,19): expected-closing-tag-but-got-eof" - ], - "document": { - "props": { - "tags": { - "html": true, - "head": true, - "body": true, - "p": true, - "b": true - } - }, - "tree": [ - { - "tag": "html", - "children": [ - { - "tag": "head" - }, - { - "tag": "body", - "children": [ - { - "tag": "p", - "children": [ - { - "tag": "b", - "children": [ - { - "tag": "b", - "children": [ - { - "tag": "b", - "children": [ - { - "tag": "b" - } - ] - } - ] - } - ] - } - ] - }, - { - "tag": "p", - "children": [ - { - "tag": "b", - "children": [ - { - "tag": "b", - "children": [ - { - "tag": "b", - "children": [ - { - "text": "x" - } - ] - } - ] - } - ] - } - ] - } - ] - } - ] - } - ], - "html": "

    x

    ", - "noQuirksBodyHtml": "

    x

    " - } - }, - { - "data": "