From b1282b6f6a7ca941016317707abbfbe057960f8f Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 25 Jan 2019 22:07:37 -0500 Subject: [PATCH 001/142] Upgrade to PHP 7.1 and PHPUnit 7. --- .gitignore | 1 + lib/Conf.php | 2 +- vendor-bin/csfixer/composer.lock | 184 ++++++++------- vendor-bin/phpunit/composer.json | 2 +- vendor-bin/phpunit/composer.lock | 380 ++++++++++++++++--------------- vendor-bin/robo/composer.lock | 314 ++++++++++++++++--------- 6 files changed, 503 insertions(+), 380 deletions(-) diff --git a/.gitignore b/.gitignore index 31a77e3..3e5e003 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /vendor/ /vendor-bin/*/vendor /documentation/ +/manual/ /tests/coverage/ /arsse.db* /config.php diff --git a/lib/Conf.php b/lib/Conf.php index bba5821..15a8345 100644 --- a/lib/Conf.php +++ b/lib/Conf.php @@ -286,7 +286,7 @@ class Conf { } switch (self::EXPECTED_TYPES[$key] ?? gettype($this->$key)) { case "integer": - return Value::normalize($value, Value::T_INT | $mode); + return Value::normalize($value, Value::T_INT | $mode); // @codeCoverageIgnore case "double": return Value::normalize($value, Value::T_FLOAT | $mode); case "string": diff --git a/vendor-bin/csfixer/composer.lock b/vendor-bin/csfixer/composer.lock index fe1d67e..f522cb4 100644 --- a/vendor-bin/csfixer/composer.lock +++ b/vendor-bin/csfixer/composer.lock @@ -114,30 +114,30 @@ }, { "name": "doctrine/annotations", - "version": "v1.4.0", + "version": "v1.6.0", "source": { "type": "git", "url": "https://github.com/doctrine/annotations.git", - "reference": "54cacc9b81758b14e3ce750f205a393d52339e97" + "reference": "c7f2050c68a9ab0bdb0f98567ec08d80ea7d24d5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/annotations/zipball/54cacc9b81758b14e3ce750f205a393d52339e97", - "reference": "54cacc9b81758b14e3ce750f205a393d52339e97", + "url": "https://api.github.com/repos/doctrine/annotations/zipball/c7f2050c68a9ab0bdb0f98567ec08d80ea7d24d5", + "reference": "c7f2050c68a9ab0bdb0f98567ec08d80ea7d24d5", "shasum": "" }, "require": { "doctrine/lexer": "1.*", - "php": "^5.6 || ^7.0" + "php": "^7.1" }, "require-dev": { "doctrine/cache": "1.*", - "phpunit/phpunit": "^5.7" + "phpunit/phpunit": "^6.4" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.4.x-dev" + "dev-master": "1.6.x-dev" } }, "autoload": { @@ -178,7 +178,7 @@ "docblock", "parser" ], - "time": "2017-02-24T16:22:25+00:00" + "time": "2017-12-06T07:11:42+00:00" }, { "name": "doctrine/lexer", @@ -475,21 +475,21 @@ }, { "name": "symfony/console", - "version": "v3.4.21", + "version": "v4.2.2", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "a700b874d3692bc8342199adfb6d3b99f62cc61a" + "reference": "b0a03c1bb0fcbe288629956cf2f1dd3f1dc97522" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/a700b874d3692bc8342199adfb6d3b99f62cc61a", - "reference": "a700b874d3692bc8342199adfb6d3b99f62cc61a", + "url": "https://api.github.com/repos/symfony/console/zipball/b0a03c1bb0fcbe288629956cf2f1dd3f1dc97522", + "reference": "b0a03c1bb0fcbe288629956cf2f1dd3f1dc97522", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", - "symfony/debug": "~2.8|~3.0|~4.0", + "php": "^7.1.3", + "symfony/contracts": "^1.0", "symfony/polyfill-mbstring": "~1.0" }, "conflict": { @@ -498,11 +498,11 @@ }, "require-dev": { "psr/log": "~1.0", - "symfony/config": "~3.3|~4.0", + "symfony/config": "~3.4|~4.0", "symfony/dependency-injection": "~3.4|~4.0", - "symfony/event-dispatcher": "~2.8|~3.0|~4.0", + "symfony/event-dispatcher": "~3.4|~4.0", "symfony/lock": "~3.4|~4.0", - "symfony/process": "~3.3|~4.0" + "symfony/process": "~3.4|~4.0" }, "suggest": { "psr/log-implementation": "For using the console logger", @@ -513,7 +513,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.2-dev" } }, "autoload": { @@ -540,44 +540,48 @@ ], "description": "Symfony Console Component", "homepage": "https://symfony.com", - "time": "2019-01-04T04:42:43+00:00" + "time": "2019-01-04T15:13:53+00:00" }, { - "name": "symfony/debug", - "version": "v3.4.21", + "name": "symfony/contracts", + "version": "v1.0.2", "source": { "type": "git", - "url": "https://github.com/symfony/debug.git", - "reference": "26d7f23b9bd0b93bee5583e4d6ca5cb1ab31b186" + "url": "https://github.com/symfony/contracts.git", + "reference": "1aa7ab2429c3d594dd70689604b5cf7421254cdf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/debug/zipball/26d7f23b9bd0b93bee5583e4d6ca5cb1ab31b186", - "reference": "26d7f23b9bd0b93bee5583e4d6ca5cb1ab31b186", + "url": "https://api.github.com/repos/symfony/contracts/zipball/1aa7ab2429c3d594dd70689604b5cf7421254cdf", + "reference": "1aa7ab2429c3d594dd70689604b5cf7421254cdf", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", - "psr/log": "~1.0" - }, - "conflict": { - "symfony/http-kernel": ">=2.3,<2.3.24|~2.4.0|>=2.5,<2.5.9|>=2.6,<2.6.2" + "php": "^7.1.3" }, "require-dev": { - "symfony/http-kernel": "~2.8|~3.0|~4.0" + "psr/cache": "^1.0", + "psr/container": "^1.0" + }, + "suggest": { + "psr/cache": "When using the Cache contracts", + "psr/container": "When using the Service contracts", + "symfony/cache-contracts-implementation": "", + "symfony/service-contracts-implementation": "", + "symfony/translation-contracts-implementation": "" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "1.0-dev" } }, "autoload": { "psr-4": { - "Symfony\\Component\\Debug\\": "" + "Symfony\\Contracts\\": "" }, "exclude-from-classmap": [ - "/Tests/" + "**/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -586,44 +590,53 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony Debug Component", + "description": "A set of abstractions extracted out of the Symfony components", "homepage": "https://symfony.com", - "time": "2019-01-01T13:45:19+00:00" + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "time": "2018-12-05T08:06:11+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v3.4.21", + "version": "v4.2.2", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "d1cdd46c53c264a2bd42505bd0e8ce21423bd0e2" + "reference": "887de6d34c86cf0cb6cbf910afb170cdb743cb5e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d1cdd46c53c264a2bd42505bd0e8ce21423bd0e2", - "reference": "d1cdd46c53c264a2bd42505bd0e8ce21423bd0e2", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/887de6d34c86cf0cb6cbf910afb170cdb743cb5e", + "reference": "887de6d34c86cf0cb6cbf910afb170cdb743cb5e", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "php": "^7.1.3", + "symfony/contracts": "^1.0" }, "conflict": { - "symfony/dependency-injection": "<3.3" + "symfony/dependency-injection": "<3.4" }, "require-dev": { "psr/log": "~1.0", - "symfony/config": "~2.8|~3.0|~4.0", - "symfony/dependency-injection": "~3.3|~4.0", - "symfony/expression-language": "~2.8|~3.0|~4.0", - "symfony/stopwatch": "~2.8|~3.0|~4.0" + "symfony/config": "~3.4|~4.0", + "symfony/dependency-injection": "~3.4|~4.0", + "symfony/expression-language": "~3.4|~4.0", + "symfony/stopwatch": "~3.4|~4.0" }, "suggest": { "symfony/dependency-injection": "", @@ -632,7 +645,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.2-dev" } }, "autoload": { @@ -659,30 +672,30 @@ ], "description": "Symfony EventDispatcher Component", "homepage": "https://symfony.com", - "time": "2019-01-01T18:08:36+00:00" + "time": "2019-01-05T16:37:49+00:00" }, { "name": "symfony/filesystem", - "version": "v3.4.21", + "version": "v4.2.2", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "c24ce3d18ccc9bb9d7e1d6ce9330fcc6061cafde" + "reference": "c2ffd9a93f2d6c5be2f68a0aa7953cc229f871f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/c24ce3d18ccc9bb9d7e1d6ce9330fcc6061cafde", - "reference": "c24ce3d18ccc9bb9d7e1d6ce9330fcc6061cafde", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/c2ffd9a93f2d6c5be2f68a0aa7953cc229f871f8", + "reference": "c2ffd9a93f2d6c5be2f68a0aa7953cc229f871f8", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", + "php": "^7.1.3", "symfony/polyfill-ctype": "~1.8" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.2-dev" } }, "autoload": { @@ -709,29 +722,29 @@ ], "description": "Symfony Filesystem Component", "homepage": "https://symfony.com", - "time": "2019-01-01T13:45:19+00:00" + "time": "2019-01-03T09:07:35+00:00" }, { "name": "symfony/finder", - "version": "v3.4.21", + "version": "v4.2.2", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "3f2a2ab6315dd7682d4c16dcae1e7b95c8b8555e" + "reference": "9094d69e8c6ee3fe186a0ec5a4f1401e506071ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/3f2a2ab6315dd7682d4c16dcae1e7b95c8b8555e", - "reference": "3f2a2ab6315dd7682d4c16dcae1e7b95c8b8555e", + "url": "https://api.github.com/repos/symfony/finder/zipball/9094d69e8c6ee3fe186a0ec5a4f1401e506071ce", + "reference": "9094d69e8c6ee3fe186a0ec5a4f1401e506071ce", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "php": "^7.1.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.2-dev" } }, "autoload": { @@ -758,29 +771,29 @@ ], "description": "Symfony Finder Component", "homepage": "https://symfony.com", - "time": "2019-01-01T13:45:19+00:00" + "time": "2019-01-03T09:07:35+00:00" }, { "name": "symfony/options-resolver", - "version": "v3.4.21", + "version": "v4.2.2", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "8a10e36ffd04c0c551051594952304d34ecece71" + "reference": "fbcb106aeee72f3450298bf73324d2cc00d083d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/8a10e36ffd04c0c551051594952304d34ecece71", - "reference": "8a10e36ffd04c0c551051594952304d34ecece71", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/fbcb106aeee72f3450298bf73324d2cc00d083d1", + "reference": "fbcb106aeee72f3450298bf73324d2cc00d083d1", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "php": "^7.1.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.2-dev" } }, "autoload": { @@ -812,7 +825,7 @@ "configuration", "options" ], - "time": "2019-01-01T13:45:19+00:00" + "time": "2019-01-03T09:07:35+00:00" }, { "name": "symfony/polyfill-ctype", @@ -1047,25 +1060,25 @@ }, { "name": "symfony/process", - "version": "v3.4.21", + "version": "v4.2.2", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "0d41dd7d95ed179aed6a13393b0f4f97bfa2d25c" + "reference": "ea043ab5d8ed13b467a9087d81cb876aee7f689a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/0d41dd7d95ed179aed6a13393b0f4f97bfa2d25c", - "reference": "0d41dd7d95ed179aed6a13393b0f4f97bfa2d25c", + "url": "https://api.github.com/repos/symfony/process/zipball/ea043ab5d8ed13b467a9087d81cb876aee7f689a", + "reference": "ea043ab5d8ed13b467a9087d81cb876aee7f689a", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "php": "^7.1.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.2-dev" } }, "autoload": { @@ -1092,29 +1105,30 @@ ], "description": "Symfony Process Component", "homepage": "https://symfony.com", - "time": "2019-01-02T21:24:08+00:00" + "time": "2019-01-03T14:48:52+00:00" }, { "name": "symfony/stopwatch", - "version": "v3.4.21", + "version": "v4.2.2", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "af55d31cb58c5452d2c160655fa1968b872a8084" + "reference": "af62b35760fc92c8dbdce659b4eebdfe0e6a0472" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/af55d31cb58c5452d2c160655fa1968b872a8084", - "reference": "af55d31cb58c5452d2c160655fa1968b872a8084", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/af62b35760fc92c8dbdce659b4eebdfe0e6a0472", + "reference": "af62b35760fc92c8dbdce659b4eebdfe0e6a0472", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "php": "^7.1.3", + "symfony/contracts": "^1.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.2-dev" } }, "autoload": { @@ -1141,7 +1155,7 @@ ], "description": "Symfony Stopwatch Component", "homepage": "https://symfony.com", - "time": "2019-01-01T13:45:19+00:00" + "time": "2019-01-03T09:07:35+00:00" } ], "packages-dev": [], diff --git a/vendor-bin/phpunit/composer.json b/vendor-bin/phpunit/composer.json index 2fe20f7..e0854a6 100644 --- a/vendor-bin/phpunit/composer.json +++ b/vendor-bin/phpunit/composer.json @@ -1,6 +1,6 @@ { "require": { - "phpunit/phpunit": "^6.5", + "phpunit/phpunit": "*", "phake/phake": "^3.0", "clue/arguments": "^2.0", "mikey179/vfsStream": "^1.6", diff --git a/vendor-bin/phpunit/composer.lock b/vendor-bin/phpunit/composer.lock index 53d62ca..88d83a5 100644 --- a/vendor-bin/phpunit/composer.lock +++ b/vendor-bin/phpunit/composer.lock @@ -1,10 +1,10 @@ { "_readme": [ "This file locks the dependencies of your project to a known state", - "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4252b3d7817c9a4a5f60ac81f28202e2", + "content-hash": "5c03bb6fb595eebc1bb3e5fe9ea7c4a0", "packages": [ { "name": "clue/arguments", @@ -58,32 +58,32 @@ }, { "name": "doctrine/instantiator", - "version": "1.0.5", + "version": "1.1.0", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "8e884e78f9f0eb1329e445619e04456e64d8051d" + "reference": "185b8868aa9bf7159f5f953ed5afb2d7fcdc3bda" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/8e884e78f9f0eb1329e445619e04456e64d8051d", - "reference": "8e884e78f9f0eb1329e445619e04456e64d8051d", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/185b8868aa9bf7159f5f953ed5afb2d7fcdc3bda", + "reference": "185b8868aa9bf7159f5f953ed5afb2d7fcdc3bda", "shasum": "" }, "require": { - "php": ">=5.3,<8.0-DEV" + "php": "^7.1" }, "require-dev": { "athletic/athletic": "~0.1.8", "ext-pdo": "*", "ext-phar": "*", - "phpunit/phpunit": "~4.0", - "squizlabs/php_codesniffer": "~2.0" + "phpunit/phpunit": "^6.2.3", + "squizlabs/php_codesniffer": "^3.0.2" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "1.2.x-dev" } }, "autoload": { @@ -108,7 +108,7 @@ "constructor", "instantiate" ], - "time": "2015-06-14T21:17:01+00:00" + "time": "2017-07-22T11:58:36+00:00" }, { "name": "mikey179/vfsStream", @@ -158,25 +158,28 @@ }, { "name": "myclabs/deep-copy", - "version": "1.7.0", + "version": "1.8.1", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "3b8a3a99ba1f6a3952ac2747d989303cbd6b7a3e" + "reference": "3e01bdad3e18354c3dce54466b7fbe33a9f9f7f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/3b8a3a99ba1f6a3952ac2747d989303cbd6b7a3e", - "reference": "3b8a3a99ba1f6a3952ac2747d989303cbd6b7a3e", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/3e01bdad3e18354c3dce54466b7fbe33a9f9f7f8", + "reference": "3e01bdad3e18354c3dce54466b7fbe33a9f9f7f8", "shasum": "" }, "require": { - "php": "^5.6 || ^7.0" + "php": "^7.1" + }, + "replace": { + "myclabs/deep-copy": "self.version" }, "require-dev": { "doctrine/collections": "^1.0", "doctrine/common": "^2.6", - "phpunit/phpunit": "^4.1" + "phpunit/phpunit": "^7.1" }, "type": "library", "autoload": { @@ -199,7 +202,7 @@ "object", "object graph" ], - "time": "2017-10-19T19:58:43+00:00" + "time": "2018-06-11T23:09:50+00:00" }, { "name": "phake/phake", @@ -261,22 +264,22 @@ }, { "name": "phar-io/manifest", - "version": "1.0.1", + "version": "1.0.3", "source": { "type": "git", "url": "https://github.com/phar-io/manifest.git", - "reference": "2df402786ab5368a0169091f61a7c1e0eb6852d0" + "reference": "7761fcacf03b4d4f16e7ccb606d4879ca431fcf4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/manifest/zipball/2df402786ab5368a0169091f61a7c1e0eb6852d0", - "reference": "2df402786ab5368a0169091f61a7c1e0eb6852d0", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/7761fcacf03b4d4f16e7ccb606d4879ca431fcf4", + "reference": "7761fcacf03b4d4f16e7ccb606d4879ca431fcf4", "shasum": "" }, "require": { "ext-dom": "*", "ext-phar": "*", - "phar-io/version": "^1.0.1", + "phar-io/version": "^2.0", "php": "^5.6 || ^7.0" }, "type": "library", @@ -312,20 +315,20 @@ } ], "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", - "time": "2017-03-05T18:14:27+00:00" + "time": "2018-07-08T19:23:20+00:00" }, { "name": "phar-io/version", - "version": "1.0.1", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/phar-io/version.git", - "reference": "a70c0ced4be299a63d32fa96d9281d03e94041df" + "reference": "45a2ec53a73c70ce41d55cedef9063630abaf1b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/version/zipball/a70c0ced4be299a63d32fa96d9281d03e94041df", - "reference": "a70c0ced4be299a63d32fa96d9281d03e94041df", + "url": "https://api.github.com/repos/phar-io/version/zipball/45a2ec53a73c70ce41d55cedef9063630abaf1b6", + "reference": "45a2ec53a73c70ce41d55cedef9063630abaf1b6", "shasum": "" }, "require": { @@ -359,7 +362,7 @@ } ], "description": "Library for handling version information and constraints", - "time": "2017-03-05T17:38:23+00:00" + "time": "2018-07-08T19:19:57+00:00" }, { "name": "phpdocumentor/reflection-common", @@ -578,40 +581,40 @@ }, { "name": "phpunit/php-code-coverage", - "version": "5.3.2", + "version": "6.1.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "c89677919c5dd6d3b3852f230a663118762218ac" + "reference": "807e6013b00af69b6c5d9ceb4282d0393dbb9d8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/c89677919c5dd6d3b3852f230a663118762218ac", - "reference": "c89677919c5dd6d3b3852f230a663118762218ac", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/807e6013b00af69b6c5d9ceb4282d0393dbb9d8d", + "reference": "807e6013b00af69b6c5d9ceb4282d0393dbb9d8d", "shasum": "" }, "require": { "ext-dom": "*", "ext-xmlwriter": "*", - "php": "^7.0", - "phpunit/php-file-iterator": "^1.4.2", + "php": "^7.1", + "phpunit/php-file-iterator": "^2.0", "phpunit/php-text-template": "^1.2.1", - "phpunit/php-token-stream": "^2.0.1", + "phpunit/php-token-stream": "^3.0", "sebastian/code-unit-reverse-lookup": "^1.0.1", - "sebastian/environment": "^3.0", + "sebastian/environment": "^3.1 || ^4.0", "sebastian/version": "^2.0.1", "theseer/tokenizer": "^1.1" }, "require-dev": { - "phpunit/phpunit": "^6.0" + "phpunit/phpunit": "^7.0" }, "suggest": { - "ext-xdebug": "^2.5.5" + "ext-xdebug": "^2.6.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "5.3.x-dev" + "dev-master": "6.1-dev" } }, "autoload": { @@ -637,29 +640,32 @@ "testing", "xunit" ], - "time": "2018-04-06T15:36:58+00:00" + "time": "2018-10-31T16:06:48+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "1.4.5", + "version": "2.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "730b01bc3e867237eaac355e06a36b85dd93a8b4" + "reference": "050bedf145a257b1ff02746c31894800e5122946" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/730b01bc3e867237eaac355e06a36b85dd93a8b4", - "reference": "730b01bc3e867237eaac355e06a36b85dd93a8b4", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/050bedf145a257b1ff02746c31894800e5122946", + "reference": "050bedf145a257b1ff02746c31894800e5122946", "shasum": "" }, "require": { - "php": ">=5.3.3" + "php": "^7.1" + }, + "require-dev": { + "phpunit/phpunit": "^7.1" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.4.x-dev" + "dev-master": "2.0.x-dev" } }, "autoload": { @@ -674,7 +680,7 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sb@sebastian-bergmann.de", + "email": "sebastian@phpunit.de", "role": "lead" } ], @@ -684,7 +690,7 @@ "filesystem", "iterator" ], - "time": "2017-11-27T13:52:08+00:00" + "time": "2018-09-13T20:33:42+00:00" }, { "name": "phpunit/php-text-template", @@ -729,28 +735,28 @@ }, { "name": "phpunit/php-timer", - "version": "1.0.9", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "3dcf38ca72b158baf0bc245e9184d3fdffa9c46f" + "reference": "8b8454ea6958c3dee38453d3bd571e023108c91f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3dcf38ca72b158baf0bc245e9184d3fdffa9c46f", - "reference": "3dcf38ca72b158baf0bc245e9184d3fdffa9c46f", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/8b8454ea6958c3dee38453d3bd571e023108c91f", + "reference": "8b8454ea6958c3dee38453d3bd571e023108c91f", "shasum": "" }, "require": { - "php": "^5.3.3 || ^7.0" + "php": "^7.1" }, "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0" + "phpunit/phpunit": "^7.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-master": "2.0-dev" } }, "autoload": { @@ -765,7 +771,7 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sb@sebastian-bergmann.de", + "email": "sebastian@phpunit.de", "role": "lead" } ], @@ -774,33 +780,33 @@ "keywords": [ "timer" ], - "time": "2017-02-26T11:10:40+00:00" + "time": "2018-02-01T13:07:23+00:00" }, { "name": "phpunit/php-token-stream", - "version": "2.0.2", + "version": "3.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-token-stream.git", - "reference": "791198a2c6254db10131eecfe8c06670700904db" + "reference": "c99e3be9d3e85f60646f152f9002d46ed7770d18" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/791198a2c6254db10131eecfe8c06670700904db", - "reference": "791198a2c6254db10131eecfe8c06670700904db", + "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/c99e3be9d3e85f60646f152f9002d46ed7770d18", + "reference": "c99e3be9d3e85f60646f152f9002d46ed7770d18", "shasum": "" }, "require": { "ext-tokenizer": "*", - "php": "^7.0" + "php": "^7.1" }, "require-dev": { - "phpunit/phpunit": "^6.2.4" + "phpunit/phpunit": "^7.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-master": "3.0-dev" } }, "autoload": { @@ -823,57 +829,57 @@ "keywords": [ "tokenizer" ], - "time": "2017-11-27T05:48:46+00:00" + "time": "2018-10-30T05:52:18+00:00" }, { "name": "phpunit/phpunit", - "version": "6.5.13", + "version": "7.5.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "0973426fb012359b2f18d3bd1e90ef1172839693" + "reference": "7c89093bd00f7d5ddf0ab81dee04f801416b4944" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0973426fb012359b2f18d3bd1e90ef1172839693", - "reference": "0973426fb012359b2f18d3bd1e90ef1172839693", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/7c89093bd00f7d5ddf0ab81dee04f801416b4944", + "reference": "7c89093bd00f7d5ddf0ab81dee04f801416b4944", "shasum": "" }, "require": { + "doctrine/instantiator": "^1.1", "ext-dom": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", "ext-xml": "*", - "myclabs/deep-copy": "^1.6.1", - "phar-io/manifest": "^1.0.1", - "phar-io/version": "^1.0", - "php": "^7.0", + "myclabs/deep-copy": "^1.7", + "phar-io/manifest": "^1.0.2", + "phar-io/version": "^2.0", + "php": "^7.1", "phpspec/prophecy": "^1.7", - "phpunit/php-code-coverage": "^5.3", - "phpunit/php-file-iterator": "^1.4.3", + "phpunit/php-code-coverage": "^6.0.7", + "phpunit/php-file-iterator": "^2.0.1", "phpunit/php-text-template": "^1.2.1", - "phpunit/php-timer": "^1.0.9", - "phpunit/phpunit-mock-objects": "^5.0.9", - "sebastian/comparator": "^2.1", - "sebastian/diff": "^2.0", - "sebastian/environment": "^3.1", + "phpunit/php-timer": "^2.0", + "sebastian/comparator": "^3.0", + "sebastian/diff": "^3.0", + "sebastian/environment": "^4.0", "sebastian/exporter": "^3.1", "sebastian/global-state": "^2.0", "sebastian/object-enumerator": "^3.0.3", - "sebastian/resource-operations": "^1.0", + "sebastian/resource-operations": "^2.0", "sebastian/version": "^2.0.1" }, "conflict": { - "phpdocumentor/reflection-docblock": "3.0.2", - "phpunit/dbunit": "<3.0" + "phpunit/phpunit-mock-objects": "*" }, "require-dev": { "ext-pdo": "*" }, "suggest": { + "ext-soap": "*", "ext-xdebug": "*", - "phpunit/php-invoker": "^1.1" + "phpunit/php-invoker": "^2.0" }, "bin": [ "phpunit" @@ -881,7 +887,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "6.5.x-dev" + "dev-master": "7.5-dev" } }, "autoload": { @@ -907,66 +913,7 @@ "testing", "xunit" ], - "time": "2018-09-08T15:10:43+00:00" - }, - { - "name": "phpunit/phpunit-mock-objects", - "version": "5.0.10", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/phpunit-mock-objects.git", - "reference": "cd1cf05c553ecfec36b170070573e540b67d3f1f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/cd1cf05c553ecfec36b170070573e540b67d3f1f", - "reference": "cd1cf05c553ecfec36b170070573e540b67d3f1f", - "shasum": "" - }, - "require": { - "doctrine/instantiator": "^1.0.5", - "php": "^7.0", - "phpunit/php-text-template": "^1.2.1", - "sebastian/exporter": "^3.1" - }, - "conflict": { - "phpunit/phpunit": "<6.0" - }, - "require-dev": { - "phpunit/phpunit": "^6.5.11" - }, - "suggest": { - "ext-soap": "*" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.0.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Mock Object library for PHPUnit", - "homepage": "https://github.com/sebastianbergmann/phpunit-mock-objects/", - "keywords": [ - "mock", - "xunit" - ], - "time": "2018-08-09T05:50:03+00:00" + "time": "2019-01-15T08:19:08+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", @@ -1015,30 +962,30 @@ }, { "name": "sebastian/comparator", - "version": "2.1.3", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "34369daee48eafb2651bea869b4b15d75ccc35f9" + "reference": "5de4fc177adf9bce8df98d8d141a7559d7ccf6da" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/34369daee48eafb2651bea869b4b15d75ccc35f9", - "reference": "34369daee48eafb2651bea869b4b15d75ccc35f9", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/5de4fc177adf9bce8df98d8d141a7559d7ccf6da", + "reference": "5de4fc177adf9bce8df98d8d141a7559d7ccf6da", "shasum": "" }, "require": { - "php": "^7.0", - "sebastian/diff": "^2.0 || ^3.0", + "php": "^7.1", + "sebastian/diff": "^3.0", "sebastian/exporter": "^3.1" }, "require-dev": { - "phpunit/phpunit": "^6.4" + "phpunit/phpunit": "^7.1" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.1.x-dev" + "dev-master": "3.0-dev" } }, "autoload": { @@ -1075,32 +1022,33 @@ "compare", "equality" ], - "time": "2018-02-01T13:46:46+00:00" + "time": "2018-07-12T15:12:46+00:00" }, { "name": "sebastian/diff", - "version": "2.0.1", + "version": "3.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "347c1d8b49c5c3ee30c7040ea6fc446790e6bddd" + "reference": "366541b989927187c4ca70490a35615d3fef2dce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/347c1d8b49c5c3ee30c7040ea6fc446790e6bddd", - "reference": "347c1d8b49c5c3ee30c7040ea6fc446790e6bddd", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/366541b989927187c4ca70490a35615d3fef2dce", + "reference": "366541b989927187c4ca70490a35615d3fef2dce", "shasum": "" }, "require": { - "php": "^7.0" + "php": "^7.1" }, "require-dev": { - "phpunit/phpunit": "^6.2" + "phpunit/phpunit": "^7.0", + "symfony/process": "^2 || ^3.3 || ^4" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-master": "3.0-dev" } }, "autoload": { @@ -1125,34 +1073,37 @@ "description": "Diff implementation", "homepage": "https://github.com/sebastianbergmann/diff", "keywords": [ - "diff" + "diff", + "udiff", + "unidiff", + "unified diff" ], - "time": "2017-08-03T08:09:46+00:00" + "time": "2018-06-10T07:54:39+00:00" }, { "name": "sebastian/environment", - "version": "3.1.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "cd0871b3975fb7fc44d11314fd1ee20925fce4f5" + "reference": "febd209a219cea7b56ad799b30ebbea34b71eb8f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/cd0871b3975fb7fc44d11314fd1ee20925fce4f5", - "reference": "cd0871b3975fb7fc44d11314fd1ee20925fce4f5", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/febd209a219cea7b56ad799b30ebbea34b71eb8f", + "reference": "febd209a219cea7b56ad799b30ebbea34b71eb8f", "shasum": "" }, "require": { - "php": "^7.0" + "php": "^7.1" }, "require-dev": { - "phpunit/phpunit": "^6.1" + "phpunit/phpunit": "^7.4" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.1.x-dev" + "dev-master": "4.0-dev" } }, "autoload": { @@ -1177,7 +1128,7 @@ "environment", "hhvm" ], - "time": "2017-07-01T08:51:00+00:00" + "time": "2018-11-25T09:31:21+00:00" }, { "name": "sebastian/exporter", @@ -1444,25 +1395,25 @@ }, { "name": "sebastian/resource-operations", - "version": "1.0.0", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/resource-operations.git", - "reference": "ce990bb21759f94aeafd30209e8cfcdfa8bc3f52" + "reference": "4d7a795d35b889bf80a0cc04e08d77cedfa917a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/ce990bb21759f94aeafd30209e8cfcdfa8bc3f52", - "reference": "ce990bb21759f94aeafd30209e8cfcdfa8bc3f52", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/4d7a795d35b889bf80a0cc04e08d77cedfa917a9", + "reference": "4d7a795d35b889bf80a0cc04e08d77cedfa917a9", "shasum": "" }, "require": { - "php": ">=5.6.0" + "php": "^7.1" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "2.0-dev" } }, "autoload": { @@ -1482,7 +1433,7 @@ ], "description": "Provides a list of PHP built-in functions that operate on resources", "homepage": "https://www.github.com/sebastianbergmann/resource-operations", - "time": "2015-07-28T20:34:47+00:00" + "time": "2018-10-04T04:07:39+00:00" }, { "name": "sebastian/version", @@ -1527,6 +1478,64 @@ "homepage": "https://github.com/sebastianbergmann/version", "time": "2016-10-03T07:35:21+00:00" }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.10.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "e3d826245268269cd66f8326bd8bc066687b4a19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/e3d826245268269cd66f8326bd8bc066687b4a19", + "reference": "e3d826245268269cd66f8326bd8bc066687b4a19", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + }, + { + "name": "Gert de Pagter", + "email": "backendtea@gmail.com" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "time": "2018-08-06T14:22:27+00:00" + }, { "name": "theseer/tokenizer", "version": "1.1.0", @@ -1569,20 +1578,21 @@ }, { "name": "webmozart/assert", - "version": "1.3.0", + "version": "1.4.0", "source": { "type": "git", "url": "https://github.com/webmozart/assert.git", - "reference": "0df1908962e7a3071564e857d86874dad1ef204a" + "reference": "83e253c8e0be5b0257b881e1827274667c5c17a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozart/assert/zipball/0df1908962e7a3071564e857d86874dad1ef204a", - "reference": "0df1908962e7a3071564e857d86874dad1ef204a", + "url": "https://api.github.com/repos/webmozart/assert/zipball/83e253c8e0be5b0257b881e1827274667c5c17a9", + "reference": "83e253c8e0be5b0257b881e1827274667c5c17a9", "shasum": "" }, "require": { - "php": "^5.3.3 || ^7.0" + "php": "^5.3.3 || ^7.0", + "symfony/polyfill-ctype": "^1.8" }, "require-dev": { "phpunit/phpunit": "^4.6", @@ -1615,7 +1625,7 @@ "check", "validate" ], - "time": "2018-01-29T19:49:41+00:00" + "time": "2018-12-25T11:19:39+00:00" }, { "name": "webmozart/glob", diff --git a/vendor-bin/robo/composer.lock b/vendor-bin/robo/composer.lock index 09a7ac7..123dceb 100644 --- a/vendor-bin/robo/composer.lock +++ b/vendor-bin/robo/composer.lock @@ -1,23 +1,23 @@ { "_readme": [ "This file locks the dependencies of your project to a known state", - "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], "content-hash": "87a37068875d67919f797af9dc08e108", "packages": [ { "name": "consolidation/annotated-command", - "version": "2.10.0", + "version": "2.11.0", "source": { "type": "git", "url": "https://github.com/consolidation/annotated-command.git", - "reference": "8e7d1a05230dc1159c751809e98b74f2b7f71873" + "reference": "edea407f57104ed518cc3c3b47d5b84403ee267a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/consolidation/annotated-command/zipball/8e7d1a05230dc1159c751809e98b74f2b7f71873", - "reference": "8e7d1a05230dc1159c751809e98b74f2b7f71873", + "url": "https://api.github.com/repos/consolidation/annotated-command/zipball/edea407f57104ed518cc3c3b47d5b84403ee267a", + "reference": "edea407f57104ed518cc3c3b47d5b84403ee267a", "shasum": "" }, "require": { @@ -29,13 +29,57 @@ "symfony/finder": "^2.5|^3|^4" }, "require-dev": { - "g1a/composer-test-scenarios": "^2", + "g1a/composer-test-scenarios": "^3", + "php-coveralls/php-coveralls": "^1", "phpunit/phpunit": "^6", - "satooshi/php-coveralls": "^2", "squizlabs/php_codesniffer": "^2.7" }, "type": "library", "extra": { + "scenarios": { + "symfony4": { + "require": { + "symfony/console": "^4.0" + }, + "config": { + "platform": { + "php": "7.1.3" + } + } + }, + "symfony2": { + "require": { + "symfony/console": "^2.8" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.36" + }, + "remove": [ + "php-coveralls/php-coveralls" + ], + "config": { + "platform": { + "php": "5.4.8" + } + }, + "scenario-options": { + "create-lockfile": "false" + } + }, + "phpunit4": { + "require-dev": { + "phpunit/phpunit": "^4.8.36" + }, + "remove": [ + "php-coveralls/php-coveralls" + ], + "config": { + "platform": { + "php": "5.4.8" + } + } + } + }, "branch-alias": { "dev-master": "2.x-dev" } @@ -56,7 +100,7 @@ } ], "description": "Initialize Symfony Console commands from annotated command class methods.", - "time": "2018-11-15T01:46:18+00:00" + "time": "2018-12-29T04:43:17+00:00" }, { "name": "consolidation/config", @@ -114,31 +158,72 @@ }, { "name": "consolidation/log", - "version": "1.0.6", + "version": "1.1.1", "source": { "type": "git", "url": "https://github.com/consolidation/log.git", - "reference": "dfd8189a771fe047bf3cd669111b2de5f1c79395" + "reference": "b2e887325ee90abc96b0a8b7b474cd9e7c896e3a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/consolidation/log/zipball/dfd8189a771fe047bf3cd669111b2de5f1c79395", - "reference": "dfd8189a771fe047bf3cd669111b2de5f1c79395", + "url": "https://api.github.com/repos/consolidation/log/zipball/b2e887325ee90abc96b0a8b7b474cd9e7c896e3a", + "reference": "b2e887325ee90abc96b0a8b7b474cd9e7c896e3a", "shasum": "" }, "require": { - "php": ">=5.5.0", - "psr/log": "~1.0", + "php": ">=5.4.5", + "psr/log": "^1.0", "symfony/console": "^2.8|^3|^4" }, "require-dev": { - "g1a/composer-test-scenarios": "^1", - "phpunit/phpunit": "4.*", - "satooshi/php-coveralls": "^2", - "squizlabs/php_codesniffer": "2.*" + "g1a/composer-test-scenarios": "^3", + "php-coveralls/php-coveralls": "^1", + "phpunit/phpunit": "^6", + "squizlabs/php_codesniffer": "^2" }, "type": "library", "extra": { + "scenarios": { + "symfony4": { + "require": { + "symfony/console": "^4.0" + }, + "config": { + "platform": { + "php": "7.1.3" + } + } + }, + "symfony2": { + "require": { + "symfony/console": "^2.8" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.36" + }, + "remove": [ + "php-coveralls/php-coveralls" + ], + "config": { + "platform": { + "php": "5.4.8" + } + } + }, + "phpunit4": { + "require-dev": { + "phpunit/phpunit": "^4.8.36" + }, + "remove": [ + "php-coveralls/php-coveralls" + ], + "config": { + "platform": { + "php": "5.4.8" + } + } + } + }, "branch-alias": { "dev-master": "1.x-dev" } @@ -159,7 +244,7 @@ } ], "description": "Improved Psr-3 / Psr\\Log logger based on Symfony Console components.", - "time": "2018-05-25T18:14:39+00:00" + "time": "2019-01-01T17:30:51+00:00" }, { "name": "consolidation/output-formatters", @@ -219,20 +304,20 @@ }, { "name": "consolidation/robo", - "version": "1.3.2", + "version": "1.4.3", "source": { "type": "git", "url": "https://github.com/consolidation/Robo.git", - "reference": "a9bd9ecf00751aa92754903c0d17612c4e840ce8" + "reference": "d0b6f516ec940add7abed4f1432d30cca5f8ae0c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/consolidation/Robo/zipball/a9bd9ecf00751aa92754903c0d17612c4e840ce8", - "reference": "a9bd9ecf00751aa92754903c0d17612c4e840ce8", + "url": "https://api.github.com/repos/consolidation/Robo/zipball/d0b6f516ec940add7abed4f1432d30cca5f8ae0c", + "reference": "d0b6f516ec940add7abed4f1432d30cca5f8ae0c", "shasum": "" }, "require": { - "consolidation/annotated-command": "^2.8.2", + "consolidation/annotated-command": "^2.10.2", "consolidation/config": "^1.0.10", "consolidation/log": "~1", "consolidation/output-formatters": "^3.1.13", @@ -304,7 +389,7 @@ } }, "branch-alias": { - "dev-master": "1.x-dev" + "dev-master": "2.x-dev" } }, "autoload": { @@ -323,7 +408,7 @@ } ], "description": "Modern task runner", - "time": "2018-11-22T05:43:44+00:00" + "time": "2019-01-02T21:33:28+00:00" }, { "name": "consolidation/self-update", @@ -627,16 +712,16 @@ }, { "name": "pear/archive_tar", - "version": "1.4.3", + "version": "1.4.5", "source": { "type": "git", "url": "https://github.com/pear/Archive_Tar.git", - "reference": "43455c960da70e655c6bdf8ea2bc8cc1a6034afb" + "reference": "ff716ca697c5e9e8593212cb785ffd03ee11b01f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pear/Archive_Tar/zipball/43455c960da70e655c6bdf8ea2bc8cc1a6034afb", - "reference": "43455c960da70e655c6bdf8ea2bc8cc1a6034afb", + "url": "https://api.github.com/repos/pear/Archive_Tar/zipball/ff716ca697c5e9e8593212cb785ffd03ee11b01f", + "reference": "ff716ca697c5e9e8593212cb785ffd03ee11b01f", "shasum": "" }, "require": { @@ -647,8 +732,8 @@ "phpunit/phpunit": "*" }, "suggest": { - "ext-bz2": "bz2 compression support.", - "ext-xz": "lzma2 compression support.", + "ext-bz2": "Bz2 compression support.", + "ext-xz": "Lzma2 compression support.", "ext-zlib": "Gzip compression support." }, "type": "library", @@ -683,13 +768,13 @@ "email": "mrook@php.net" } ], - "description": "Tar file management class", + "description": "Tar file management class with compression support (gzip, bzip2, lzma2)", "homepage": "https://github.com/pear/Archive_Tar", "keywords": [ "archive", "tar" ], - "time": "2017-06-11T17:28:11+00:00" + "time": "2019-01-02T21:45:13+00:00" }, { "name": "pear/console_getopt", @@ -740,20 +825,20 @@ }, { "name": "pear/pear-core-minimal", - "version": "v1.10.6", + "version": "v1.10.7", "source": { "type": "git", "url": "https://github.com/pear/pear-core-minimal.git", - "reference": "052868b244d31f822796e7e9981f62557eb256d4" + "reference": "19a3e0fcd50492c4357372f623f55f1b144346da" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pear/pear-core-minimal/zipball/052868b244d31f822796e7e9981f62557eb256d4", - "reference": "052868b244d31f822796e7e9981f62557eb256d4", + "url": "https://api.github.com/repos/pear/pear-core-minimal/zipball/19a3e0fcd50492c4357372f623f55f1b144346da", + "reference": "19a3e0fcd50492c4357372f623f55f1b144346da", "shasum": "" }, "require": { - "pear/console_getopt": "~1.3", + "pear/console_getopt": "~1.4", "pear/pear_exception": "~1.0" }, "replace": { @@ -780,7 +865,7 @@ } ], "description": "Minimal set of PEAR core files to be used as composer dependency", - "time": "2018-08-22T19:28:09+00:00" + "time": "2018-12-05T20:03:52+00:00" }, { "name": "pear/pear_exception", @@ -935,21 +1020,21 @@ }, { "name": "symfony/console", - "version": "v3.4.18", + "version": "v4.2.2", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "1d228fb4602047d7b26a0554e0d3efd567da5803" + "reference": "b0a03c1bb0fcbe288629956cf2f1dd3f1dc97522" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/1d228fb4602047d7b26a0554e0d3efd567da5803", - "reference": "1d228fb4602047d7b26a0554e0d3efd567da5803", + "url": "https://api.github.com/repos/symfony/console/zipball/b0a03c1bb0fcbe288629956cf2f1dd3f1dc97522", + "reference": "b0a03c1bb0fcbe288629956cf2f1dd3f1dc97522", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", - "symfony/debug": "~2.8|~3.0|~4.0", + "php": "^7.1.3", + "symfony/contracts": "^1.0", "symfony/polyfill-mbstring": "~1.0" }, "conflict": { @@ -958,11 +1043,11 @@ }, "require-dev": { "psr/log": "~1.0", - "symfony/config": "~3.3|~4.0", + "symfony/config": "~3.4|~4.0", "symfony/dependency-injection": "~3.4|~4.0", - "symfony/event-dispatcher": "~2.8|~3.0|~4.0", + "symfony/event-dispatcher": "~3.4|~4.0", "symfony/lock": "~3.4|~4.0", - "symfony/process": "~3.3|~4.0" + "symfony/process": "~3.4|~4.0" }, "suggest": { "psr/log-implementation": "For using the console logger", @@ -973,7 +1058,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.2-dev" } }, "autoload": { @@ -1000,44 +1085,48 @@ ], "description": "Symfony Console Component", "homepage": "https://symfony.com", - "time": "2018-10-30T16:50:50+00:00" + "time": "2019-01-04T15:13:53+00:00" }, { - "name": "symfony/debug", - "version": "v3.4.18", + "name": "symfony/contracts", + "version": "v1.0.2", "source": { "type": "git", - "url": "https://github.com/symfony/debug.git", - "reference": "fe9793af008b651c5441bdeab21ede8172dab097" + "url": "https://github.com/symfony/contracts.git", + "reference": "1aa7ab2429c3d594dd70689604b5cf7421254cdf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/debug/zipball/fe9793af008b651c5441bdeab21ede8172dab097", - "reference": "fe9793af008b651c5441bdeab21ede8172dab097", + "url": "https://api.github.com/repos/symfony/contracts/zipball/1aa7ab2429c3d594dd70689604b5cf7421254cdf", + "reference": "1aa7ab2429c3d594dd70689604b5cf7421254cdf", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", - "psr/log": "~1.0" - }, - "conflict": { - "symfony/http-kernel": ">=2.3,<2.3.24|~2.4.0|>=2.5,<2.5.9|>=2.6,<2.6.2" + "php": "^7.1.3" }, "require-dev": { - "symfony/http-kernel": "~2.8|~3.0|~4.0" + "psr/cache": "^1.0", + "psr/container": "^1.0" + }, + "suggest": { + "psr/cache": "When using the Cache contracts", + "psr/container": "When using the Service contracts", + "symfony/cache-contracts-implementation": "", + "symfony/service-contracts-implementation": "", + "symfony/translation-contracts-implementation": "" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "1.0-dev" } }, "autoload": { "psr-4": { - "Symfony\\Component\\Debug\\": "" + "Symfony\\Contracts\\": "" }, "exclude-from-classmap": [ - "/Tests/" + "**/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -1046,44 +1135,53 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony Debug Component", + "description": "A set of abstractions extracted out of the Symfony components", "homepage": "https://symfony.com", - "time": "2018-10-31T09:06:03+00:00" + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "time": "2018-12-05T08:06:11+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v3.4.18", + "version": "v4.2.2", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "db9e829c8f34c3d35cf37fcd4cdb4293bc4a2f14" + "reference": "887de6d34c86cf0cb6cbf910afb170cdb743cb5e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/db9e829c8f34c3d35cf37fcd4cdb4293bc4a2f14", - "reference": "db9e829c8f34c3d35cf37fcd4cdb4293bc4a2f14", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/887de6d34c86cf0cb6cbf910afb170cdb743cb5e", + "reference": "887de6d34c86cf0cb6cbf910afb170cdb743cb5e", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "php": "^7.1.3", + "symfony/contracts": "^1.0" }, "conflict": { - "symfony/dependency-injection": "<3.3" + "symfony/dependency-injection": "<3.4" }, "require-dev": { "psr/log": "~1.0", - "symfony/config": "~2.8|~3.0|~4.0", - "symfony/dependency-injection": "~3.3|~4.0", - "symfony/expression-language": "~2.8|~3.0|~4.0", - "symfony/stopwatch": "~2.8|~3.0|~4.0" + "symfony/config": "~3.4|~4.0", + "symfony/dependency-injection": "~3.4|~4.0", + "symfony/expression-language": "~3.4|~4.0", + "symfony/stopwatch": "~3.4|~4.0" }, "suggest": { "symfony/dependency-injection": "", @@ -1092,7 +1190,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.2-dev" } }, "autoload": { @@ -1119,30 +1217,30 @@ ], "description": "Symfony EventDispatcher Component", "homepage": "https://symfony.com", - "time": "2018-10-30T16:50:50+00:00" + "time": "2019-01-05T16:37:49+00:00" }, { "name": "symfony/filesystem", - "version": "v3.4.18", + "version": "v4.2.2", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "d69930fc337d767607267d57c20a7403d0a822a4" + "reference": "c2ffd9a93f2d6c5be2f68a0aa7953cc229f871f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/d69930fc337d767607267d57c20a7403d0a822a4", - "reference": "d69930fc337d767607267d57c20a7403d0a822a4", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/c2ffd9a93f2d6c5be2f68a0aa7953cc229f871f8", + "reference": "c2ffd9a93f2d6c5be2f68a0aa7953cc229f871f8", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", + "php": "^7.1.3", "symfony/polyfill-ctype": "~1.8" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.2-dev" } }, "autoload": { @@ -1169,29 +1267,29 @@ ], "description": "Symfony Filesystem Component", "homepage": "https://symfony.com", - "time": "2018-10-02T12:28:39+00:00" + "time": "2019-01-03T09:07:35+00:00" }, { "name": "symfony/finder", - "version": "v3.4.18", + "version": "v4.2.2", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "54ba444dddc5bd5708a34bd095ea67c6eb54644d" + "reference": "9094d69e8c6ee3fe186a0ec5a4f1401e506071ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/54ba444dddc5bd5708a34bd095ea67c6eb54644d", - "reference": "54ba444dddc5bd5708a34bd095ea67c6eb54644d", + "url": "https://api.github.com/repos/symfony/finder/zipball/9094d69e8c6ee3fe186a0ec5a4f1401e506071ce", + "reference": "9094d69e8c6ee3fe186a0ec5a4f1401e506071ce", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "php": "^7.1.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.2-dev" } }, "autoload": { @@ -1218,7 +1316,7 @@ ], "description": "Symfony Finder Component", "homepage": "https://symfony.com", - "time": "2018-10-03T08:46:40+00:00" + "time": "2019-01-03T09:07:35+00:00" }, { "name": "symfony/polyfill-ctype", @@ -1339,16 +1437,16 @@ }, { "name": "symfony/process", - "version": "v3.4.18", + "version": "v3.4.21", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "35c2914a9f50519bd207164c353ae4d59182c2cb" + "reference": "0d41dd7d95ed179aed6a13393b0f4f97bfa2d25c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/35c2914a9f50519bd207164c353ae4d59182c2cb", - "reference": "35c2914a9f50519bd207164c353ae4d59182c2cb", + "url": "https://api.github.com/repos/symfony/process/zipball/0d41dd7d95ed179aed6a13393b0f4f97bfa2d25c", + "reference": "0d41dd7d95ed179aed6a13393b0f4f97bfa2d25c", "shasum": "" }, "require": { @@ -1384,24 +1482,24 @@ ], "description": "Symfony Process Component", "homepage": "https://symfony.com", - "time": "2018-10-14T17:33:21+00:00" + "time": "2019-01-02T21:24:08+00:00" }, { "name": "symfony/yaml", - "version": "v3.4.18", + "version": "v4.2.2", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "640b6c27fed4066d64b64d5903a86043f4a4de7f" + "reference": "d0aa6c0ea484087927b49fd513383a7d36190ca6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/640b6c27fed4066d64b64d5903a86043f4a4de7f", - "reference": "640b6c27fed4066d64b64d5903a86043f4a4de7f", + "url": "https://api.github.com/repos/symfony/yaml/zipball/d0aa6c0ea484087927b49fd513383a7d36190ca6", + "reference": "d0aa6c0ea484087927b49fd513383a7d36190ca6", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", + "php": "^7.1.3", "symfony/polyfill-ctype": "~1.8" }, "conflict": { @@ -1416,7 +1514,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.2-dev" } }, "autoload": { @@ -1443,7 +1541,7 @@ ], "description": "Symfony Yaml Component", "homepage": "https://symfony.com", - "time": "2018-10-02T16:33:53+00:00" + "time": "2019-01-03T09:07:35+00:00" } ], "packages-dev": [], From d3a385beef78668f63da1965092710ab170b6938 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sun, 3 Feb 2019 12:25:07 -0500 Subject: [PATCH 002/142] Partial API documentation for the Database class --- lib/Database.php | 197 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) diff --git a/lib/Database.php b/lib/Database.php index c3ac4c0..af3a7e6 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -14,8 +14,11 @@ use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\ValueInfo; class Database { + /** The version number of the latest schema the interface is aware of */ const SCHEMA_VERSION = 4; + /** The maximum number of articles to mark in one query without chunking */ const LIMIT_ARTICLES = 50; + /** A map database driver short-names and their associated class names */ const DRIVER_NAMES = [ 'sqlite3' => \JKingWeb\Arsse\Db\SQLite3\Driver::class, 'postgresql' => \JKingWeb\Arsse\Db\PostgreSQL\Driver::class, @@ -25,6 +28,10 @@ class Database { /** @var Db\Driver */ public $db; + /** Constructs the database interface + * + * @param boolean $initialize Whether to attempt to upgrade the databse schema when constructing + */ public function __construct($initialize = true) { $driver = Arsse::$conf->dbDriver; $this->db = $driver::create(); @@ -34,10 +41,14 @@ class Database { } } + /** Returns the bare name of the calling context's calling method, when __FUNCTION__ is not appropriate */ protected function caller(): string { return debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]['function']; } + /** Lists the available database drivers, as an associative array with + * fully-qualified class names as keys, and human-readable descriptions as values + */ public static function driverList(): array { $sep = \DIRECTORY_SEPARATOR; $path = __DIR__.$sep."Db".$sep; @@ -50,10 +61,12 @@ class Database { return $classes; } + /** Returns the current (actual) schema version of the database; compared against self::SCHEMA_VERSION to know when an upgrade is required */ public function driverSchemaVersion(): int { return $this->db->schemaVersion(); } + /** Attempts to update the database schema. If it is already up to date, false is returned */ public function driverSchemaUpdate(): bool { if ($this->db->schemaVersion() < self::SCHEMA_VERSION) { return $this->db->schemaUpdate(self::SCHEMA_VERSION); @@ -61,10 +74,18 @@ class Database { return false; } + /** Returns whether the database's character set is Unicode */ public function driverCharsetAcceptable(): bool { return $this->db->charsetAcceptable(); } + /** Computes the column and value text of an SQL "SET" clause, validating arbitrary input against a whitelist + * + * Returns an indexed array containing the clause text, an array of types, and another array of values + * + * @param array $props An associative array containing untrusted data; keys are column names + * @param array $valid An associative array containing a whitelist: keys are column names, and values are strings representing data types + */ protected function generateSet(array $props, array $valid): array { $out = [ [], // query clause @@ -83,6 +104,13 @@ class Database { return $out; } + /** Conputes the contents of an SQL "IN()" clause, producing one parameter placeholder for each input value + * + * Returns an indexed array containing the clause text, and an array of types + * + * @param array $values Arbitrary values + * @param string $type A single data type applied to each value + */ protected function generateIn(array $values, string $type): array { $out = [ "", // query clause @@ -100,14 +128,17 @@ class Database { return $out; } + /** Returns a Transaction object, which is rolled back unless explicitly committed */ public function begin(): Db\Transaction { return $this->db->begin(); } + /** Retrieve a value from the metadata table. If the key is not set null is returned */ public function metaGet(string $key) { return $this->db->prepare("SELECT value from arsse_meta where \"key\" = ?", "str")->run($key)->getValue(); } + /** Sets the given key in the metadata table to the given value. If the key already exists it is silently overwritten */ public function metaSet(string $key, $value, string $type = "str"): bool { $out = $this->db->prepare("UPDATE arsse_meta set value = ? where \"key\" = ?", $type, "str")->run($value, $key)->changes(); if (!$out) { @@ -116,10 +147,12 @@ class Database { return (bool) $out; } + /** Unsets the given key in the metadata table. Returns false if the key does not exist */ public function metaRemove(string $key): bool { return (bool) $this->db->prepare("DELETE from arsse_meta where \"key\" = ?", "str")->run($key)->changes(); } + /** Returns whether the specified user exists in the database */ public function userExists(string $user): bool { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -127,6 +160,11 @@ class Database { return (bool) $this->db->prepare("SELECT count(*) from arsse_users where id = ?", "str")->run($user)->getValue(); } + /** Adds a user to the database + * + * @param string $user The user to add + * @param string $passwordThe user's password in cleartext. It will be stored hashed + */ public function userAdd(string $user, string $password): bool { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -138,6 +176,7 @@ class Database { return true; } + /** Removes a user from the database */ public function userRemove(string $user): bool { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -148,6 +187,7 @@ class Database { return true; } + /** Returns a flat, indexed array of all users in the database */ public function userList(): array { $out = []; if (!Arsse::$user->authorize("", __FUNCTION__)) { @@ -159,6 +199,7 @@ class Database { return $out; } + /** Retrieves the hashed password of a user */ public function userPasswordGet(string $user): string { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -168,6 +209,11 @@ class Database { return (string) $this->db->prepare("SELECT password from arsse_users where id = ?", "str")->run($user)->getValue(); } + /** Sets the password of an existing user + * + * @param string $user The user for whom to set the password + * @param string $password The new password, in cleartext. The password will be stored hashed + */ public function userPasswordSet(string $user, string $password): bool { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -179,6 +225,7 @@ class Database { return true; } + /** Creates a new session for the given user and returns the session identifier */ public function sessionCreate(string $user): string { // If the user isn't authorized to perform this action then throw an exception. if (!Arsse::$user->authorize($user, __FUNCTION__)) { @@ -193,6 +240,14 @@ class Database { return $id; } + /** Explicitly removes a session from the database + * + * Sessions may also be invalidated as they expire, and then be automatically pruned. + * This function can be used to explicitly invalidate a session after a user logs out + * + * @param string $user The user who owns the session to be destroyed + * @param string $id The identifier of the session to destroy + */ public function sessionDestroy(string $user, string $id): bool { // If the user isn't authorized to perform this action then throw an exception. if (!Arsse::$user->authorize($user, __FUNCTION__)) { @@ -202,6 +257,10 @@ class Database { return (bool) $this->db->prepare("DELETE FROM arsse_sessions where id = ? and \"user\" = ?", "str", "str")->run($id, $user)->changes(); } + /** Resumes a session, returning available session data + * + * This also has the side effect of refreshing the session if it is near its timeout + */ public function sessionResume(string $id): array { $maxAge = Date::sub(Arsse::$conf->userSessionLifetime); $out = $this->db->prepare("SELECT id,created,expires,\"user\" from arsse_sessions where id = ? and expires > CURRENT_TIMESTAMP and created > ?", "str", "datetime")->run($id, $maxAge)->getRow(); @@ -217,11 +276,13 @@ class Database { return $out; } + /** Deletes expires sessions from the database, returning the number of deleted sessions */ public function sessionCleanup(): int { $maxAge = Date::sub(Arsse::$conf->userSessionLifetime); return $this->db->prepare("DELETE FROM arsse_sessions where expires < CURRENT_TIMESTAMP or created < ?", "datetime")->run($maxAge)->changes(); } + /** Checks if a given future timeout is less than half the session timeout interval */ protected function sessionExpiringSoon(\DateTimeInterface $expiry): bool { // calculate half the session timeout as a number of seconds $now = time(); @@ -231,6 +292,18 @@ class Database { return (($now + $diff) >= $expiry->getTimestamp()); } + /** Adds a folder for containing newsfeed subscriptions, returning an integer identifying the created folder + * + * The $data array may contain the following keys: + * + * - "name": A folder name, which must be a non-empty string not composed solely of whitespace; this key is required + * - "parent": An integer (or null) identifying a parent folder; this key is optional + * + * If a folder with the same name and parent already exists, this is an error + * + * @param string $user The user who will own the folder + * @param array $data An associative array defining the folder + */ public function folderAdd(string $user, array $data): int { // If the user isn't authorized to perform this action then throw an exception. if (!Arsse::$user->authorize($user, __FUNCTION__)) { @@ -245,6 +318,20 @@ class Database { return $this->db->prepare("INSERT INTO arsse_folders(owner,parent,name) values(?,?,?)", "str", "int", "str")->run($user, $parent, $name)->lastId(); } + /** Returns a result set listing a user's folders + * + * Each record in the result set contains: + * + * - "id": The folder identifier, an integer + * - "name": The folder's name, a string + * - "parent": The integer identifier of the folder's parent, or null + * - "children": The number of child folders contained in the given folder + * - "feeds": The number of newsfeed subscriptions contained in the given folder, not including subscriptions in descendent folders + * + * @param string $uer The user whose folders are to be listed + * @param integer|null $parent Restricts the list to the descendents of the specified folder identifier + * @param boolean $recursive Whether to list all descendents, or only direct children + */ public function folderList(string $user, $parent = null, bool $recursive = true): Db\Result { // if the user isn't authorized to perform this action then throw an exception. if (!Arsse::$user->authorize($user, __FUNCTION__)) { @@ -270,6 +357,13 @@ class Database { return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); } + /** Deletes a folder from the database + * + * Any descendent folders are also deleted, as are all newsfeed subscriptions contained in the deleted folder tree + * + * @param string $user The user to whom the folder to be deleted belongs + * @param integer $id The identifier of the folder to delete + */ public function folderRemove(string $user, $id): bool { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -284,6 +378,7 @@ class Database { return true; } + /** Returns the identifier, name, and parent of the given folder as an associative array */ public function folderPropertiesGet(string $user, $id): array { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -298,6 +393,19 @@ class Database { return $props; } + /** Modifies the properties of a folder + * + * The $data array must contain one or more of the following keys: + * + * - "name": A new folder name, which must be a non-empty string not composed solely of whitespace + * - "parent": An integer (or null) identifying a parent folder + * + * If a folder with the new name and parent combination already exists, this is an error; it is also an error to move a folder to itself or one of its descendents + * + * @param string $user The user who owns the folder to be modified + * @param integer $id The identifier of the folder to be modified + * @param array $data An associative array of properties to modify. Anything not specified will remain unchanged + */ public function folderPropertiesSet(string $user, $id, array $data): bool { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -334,6 +442,14 @@ class Database { return (bool) $this->db->prepare("UPDATE arsse_folders set $setClause, modified = CURRENT_TIMESTAMP where owner = ? and id = ?", $setTypes, "str", "int")->run($setValues, $user, $id)->changes(); } + /** Ensures the specified folder exists and raises an exception otherwise + * + * Returns an associative array containing the id, name, and parent of the folder if it exists + * + * @param string $user The user who owns the folder to be validated + * @param integer|null $id The identifier of the folder to validate; null or zero represent the implied root folder + * @param boolean $subject Whether the folder is the subject rather than the object of the operation being performed; this only affects the semantics of the error message if validation fails + */ protected function folderValidateId(string $user, $id = null, bool $subject = false): array { // if the specified ID is not a non-negative integer (or null), this will always fail if (!ValueInfo::id($id, true)) { @@ -351,6 +467,7 @@ class Database { return $f; } + /** Ensures an operation to rename and/or move a folder does not result in a conflict or circular dependence, and raises an exception otherwise */ protected function folderValidateMove(string $user, $id = null, $parent = null, string $name = null) { $errData = ["action" => $this->caller(), "field" => "parent", 'id' => $parent]; if (!$id) { @@ -403,6 +520,12 @@ class Database { return $parent; } + /** Ensures a prospective folder name is valid, and optionally ensure it is not a duplicate if renamed + * + * @param string $name The name to check + * @param boolean $checkDuplicates Whether to also check if the new name would cause a collision + * @param integer|null $parent The parent folder context in which to check for duplication + */ protected function folderValidateName($name, bool $checkDuplicates = false, $parent = null): bool { $info = ValueInfo::str($name); if ($info & (ValueInfo::NULL | ValueInfo::EMPTY)) { @@ -424,6 +547,14 @@ class Database { } } + /** Adds a subscription to a newsfeed, and returns the numeric identifier of the added subscription + * + * @param string $user The user which will own the subscription + * @param string $url The URL of the newsfeed or discovery source + * @param string $fetchUser The user name required to access the newsfeed, if applicable + * @param string $fetchPassword The password required to fetch the newsfeed, if applicable; this will be stored in cleartext + * @param boolean $discovery Whether to perform newsfeed discovery if $url points to an HTML document + */ public function subscriptionAdd(string $user, string $url, string $fetchUser = "", string $fetchPassword = "", bool $discover = true): int { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -452,6 +583,13 @@ class Database { return $this->db->prepare('INSERT INTO arsse_subscriptions(owner,feed) values(?,?)', 'str', 'int')->run($user, $feedID)->lastId(); } + /** Lists a user's subscriptions, returning various data + * + * @param string $user The user whose subscriptions are to be listed + * @param integer|null $folder The identifier of the folder under which to list subscriptions; by default the root folder is used + * @param boolean $recursive Whether to list subscriptions of descendent folders as well as the selected folder + * @param integer|null $id The numeric identifier of a particular subscription; used internally by subscriptionPropertiesGet + */ public function subscriptionList(string $user, $folder = null, bool $recursive = true, int $id = null): Db\Result { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -494,6 +632,7 @@ class Database { return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); } + /** Returns the number of subscriptions in a folder, counting recursively */ public function subscriptionCount(string $user, $folder = null): int { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -512,6 +651,13 @@ class Database { return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); } + /** Deletes a subscription from the database + * + * This has the side effect of deleting all marks the user has set on articles + * belonging to the newsfeed, but may not delete the articles themselves, as + * other users may also be subscribed to the same newsfeed. There is also a + * configurable retention period for newsfeeds + */ public function subscriptionRemove(string $user, $id): bool { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -526,6 +672,24 @@ class Database { return true; } + /** Retrieves data about a particular subscription, as an associative array with the following keys: + * + * - "id": The numeric identifier of the subscription + * - "feed": The numeric identifier of the underlying newsfeed + * - "url": The URL of the newsfeed, after discovery and HTTP redirects + * - "title": The title of the newsfeed + * - "favicon": The URL of an icon representing the newsfeed or its source + * - "source": The URL of the source of the newsfeed i.e. its parent Web site + * - "folder": The numeric identifier (or null) of the subscription's folder + * - "top_folder": The numeric identifier (or null) of the top-level folder for the subscription + * - "pinned": Whether the subscription is pinned + * - "err_count": The count of times attempting to refresh the newsfeed has resulted in an error since the last successful retrieval + * - "err_msg": The error message of the last unsuccessful retrieval + * - "order_type": Whether articles should be sorted in reverse cronological order (2), chronological order (1), or the default (0) + * - "added": The date and time at which the subscription was added + * - "updated": The date and time at which the newsfeed was last updated (not when it was last refreshed) + * - "unread": The number of unread articles associated with the subscription + */ public function subscriptionPropertiesGet(string $user, $id): array { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -540,6 +704,19 @@ class Database { return $sub; } + /** Modifies the properties of a subscription + * + * The $data array must contain one or more of the following keys: + * + * - "title": The title of the newsfeed + * - "folder": The numeric identifier (or null) of the subscription's folder + * - "pinned": Whether the subscription is pinned + * - "order_type": Whether articles should be sorted in reverse cronological order (2), chronological order (1), or the default (0) + * + * @param string $user The user whose subscription is to be modified + * @param integer|null $id the numeric identifier of the subscription to modfify + * @param array $data An associative array of properties to modify; any keys not specified will be left unchanged + */ public function subscriptionPropertiesSet(string $user, $id, array $data): bool { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -580,6 +757,18 @@ class Database { return $out; } + /** Retrieves the URL of the icon for a subscription. + * + * Note that while the $user parameter is optional, it + * is NOT recommended to omit it, as this can lead to + * leaks of private information. The parameter is only + * optional because this is required for Tiny Tiny RSS, + * the original implementation of which leaks private + * information due to a design flaw. + * + * @param integer $id The numeric identifier of the subscription + * @param string|null $user The user who owns the subscription being queried + */ public function subscriptionFavicon(int $id, string $user = null): string { $q = new Query("SELECT favicon from arsse_feeds join arsse_subscriptions on feed = arsse_feeds.id"); $q->setWhere("arsse_subscriptions.id = ?", "int", $id); @@ -592,6 +781,14 @@ class Database { return (string) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); } + /** Ensures the specified subscription exists and raises an exception otherwise + * + * Returns an associative array containing the id of the subscription and the id of the underlying newsfeed + * + * @param string $user The user who owns the subscription to be validated + * @param integer|null $id The identifier of the subscription to validate + * @param boolean $subject Whether the subscription is the subject rather than the object of the operation being performed; this only affects the semantics of the error message if validation fails + */ protected function subscriptionValidateId(string $user, $id, bool $subject = false): array { if (!ValueInfo::id($id)) { throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "feed", 'type' => "int > 0"]); From 49cefaf5c8277960223be40c832bdef3bcb2b588 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 4 Feb 2019 13:05:48 -0500 Subject: [PATCH 003/142] Complete API documentation for the Database class --- lib/Database.php | 177 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 176 insertions(+), 1 deletion(-) diff --git a/lib/Database.php b/lib/Database.php index af3a7e6..49ebf52 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -800,11 +800,17 @@ class Database { return $out; } + /** Returns an indexed array of numeric identifiers for newsfeeds which should be refreshed */ public function feedListStale(): array { $feeds = $this->db->query("SELECT id from arsse_feeds where next_fetch <= CURRENT_TIMESTAMP")->getAll(); return array_column($feeds, 'id'); } + /** Attempts to refresh a newsfeed, returning an indication of success + * + * @param integer $feedID The numerical identifier of the newsfeed to refresh + * @param boolean $throwError Whether to throw an exception on failure in addition to storing error information in the database + */ public function feedUpdate($feedID, bool $throwError = false): bool { // check to make sure the feed exists if (!ValueInfo::id($feedID)) { @@ -956,6 +962,10 @@ class Database { return true; } + /** Deletes orphaned newsfeeds from the database + * + * Newsfeeds are orphaned if no users are subscribed to them. Deleting a newsfeed also deletes its articles + */ public function feedCleanup(): bool { $tr = $this->begin(); // first unmark any feeds which are no longer orphaned @@ -973,6 +983,18 @@ class Database { return $out; } + /** Retrieves various identifiers for the latest $count articles in the given newsfeed. The identifiers are: + * + * - "id": The database record key for the article + * - "guid": The (theoretically) unique identifier for the article + * - "edited": The time at which the article was last edited, per the newsfeed + * - "url_title_hash": A cryptographic hash of the article URL and its title + * - "url_content_hash": A cryptographic hash of the article URL and its content + * - "title_content_hash": A cryptographic hash of the article title and its content + * + * @param integer $feedID The numeric identifier of the feed + * @param integer $count The number of records to return + */ public function feedMatchLatest(int $feedID, int $count): Db\Result { return $this->db->prepare( "SELECT id, edited, guid, url_title_hash, url_content_hash, title_content_hash FROM arsse_articles WHERE feed = ? ORDER BY modified desc, id desc limit ?", @@ -981,6 +1003,21 @@ class Database { )->run($feedID, $count); } + /** Retrieves various identifiers for articles in the given newsfeed which match the input identifiers. The output identifiers are: + * + * - "id": The database record key for the article + * - "guid": The (theoretically) unique identifier for the article + * - "edited": The time at which the article was last edited, per the newsfeed + * - "url_title_hash": A cryptographic hash of the article URL and its title + * - "url_content_hash": A cryptographic hash of the article URL and its content + * - "title_content_hash": A cryptographic hash of the article title and its content + * + * @param integer $feedID The numeric identifier of the feed + * @param array $ids An array of GUIDs of articles + * @param array $hashesUT An array of hashes of articles' URL and title + * @param array $hashesUC An array of hashes of articles' URL and content + * @param array $hashesTC An array of hashes of articles' title and content + */ public function feedMatchIds(int $feedID, array $ids = [], array $hashesUT = [], array $hashesUC = [], array $hashesTC = []): Db\Result { // compile SQL IN() clauses and necessary type bindings for the four identifier lists list($cId, $tId) = $this->generateIn($ids, "str"); @@ -998,6 +1035,14 @@ class Database { )->run($feedID, $ids, $hashesUT, $hashesUC, $hashesTC); } + /** Computes an SQL query to find and retrieve data about articles in the database + * + * If an empty column list is supplied, a count of articles matching the context is queried instead + * + * @param string $user The user whose articles are to be queried + * @param Context $context The search context + * @param array $cols The columns to request in the result set + */ protected function articleQuery(string $user, Context $context, array $cols = ["id"]): Query { $greatest = $this->db->sqlToken("greatest"); // prepare the output column list @@ -1022,7 +1067,6 @@ class Database { 'subscription_title' => "coalesce(arsse_subscriptions.title, arsse_feeds.title)", 'media_url' => "arsse_enclosures.url", 'media_type' => "arsse_enclosures.type", - ]; if (!$cols) { // if no columns are specified return a count @@ -1160,6 +1204,7 @@ class Database { return $q; } + /** Chunk a context with more than the maximum number of articles or editions into an array of contexts */ protected function contextChunk(Context $context): array { $exception = ""; if ($context->editions()) { @@ -1184,6 +1229,15 @@ class Database { } } + + /** Lists articles in the database which match a given query context + * + * If an empty column list is supplied, a count of articles is returned instead + * + * @param string $user The user whose articles are to be listed + * @param Context $context The search context + * @param array $cols The columns to return in the result set, any of: id, edition, url, title, author, content, guid, fingerprint, subscription, feed, starred, unread, note, published_date, edited_date, modified_date, marked_date, subscription_title, media_url, media_type + */ public function articleList(string $user, Context $context = null, array $fields = ["id"]): Db\Result { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -1207,6 +1261,11 @@ class Database { } } + /** Returns a count of articles which match the given query context + * + * @param string $user The user whose articles are to be counted + * @param Context $context The search context + */ public function articleCount(string $user, Context $context = null): int { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -1227,6 +1286,18 @@ class Database { } } + /** Applies one or multiple modifications to all articles matching the given query context + * + * The $data array enumerates the modifications to perform and must contain one or more of the following keys: + * + * - "read": Whether the article should be marked as read (true) or unread (false) + * - "starred": Whether the article should (true) or should not (false) be marked as starred/favourite + * - "note": A string containing a freeform plain-text note for the article + * + * @param string $user The user who owns the articles to be modified + * @param array $data An associative array of properties to modify. Anything not specified will remain unchanged + * @param Context $context The query context to match articles against + */ public function articleMark(string $user, array $data, Context $context = null): int { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -1316,6 +1387,14 @@ class Database { } } + /** Returns statistics about the articles starred by the given user + * + * The associative array returned has the following keys: + * + * - "total": The count of all starred articles + * - "unread": The count of starred articles which are unread + * - "read": The count of starred articles which are read + */ public function articleStarred(string $user): array { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -1332,6 +1411,12 @@ class Database { )->run($user)->getRow(); } + /** Returns an indexed array listing the labels assigned to an article + * + * @param string $user The user whose labels are to be listed + * @param integer $id The numeric identifier of the article whose labels are to be listed + * @param boolean $byName Whether to return the label names instead of the numeric label identifiers + */ public function articleLabelsGet(string $user, $id, bool $byName = false): array { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -1344,6 +1429,7 @@ class Database { return $out; } + /** Returns the author-supplied categories associated with an article */ public function articleCategoriesGet(string $user, $id): array { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -1358,6 +1444,7 @@ class Database { } } + /** Deletes from the database articles which are beyond the configured clean-up threshold */ public function articleCleanup(): bool { $query = $this->db->prepare( "WITH target_feed(id,subs) as (". @@ -1404,6 +1491,13 @@ class Database { return true; } + /** Ensures the specified article exists and raises an exception otherwise + * + * Returns an associative array containing the id and latest edition of the article if it exists + * + * @param string $user The user who owns the article to be validated + * @param integer|null $id The identifier of the article to validate + */ protected function articleValidateId(string $user, $id): array { if (!ValueInfo::id($id)) { throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "article", 'type' => "int > 0"]); // @codeCoverageIgnore @@ -1426,6 +1520,13 @@ class Database { return $out; } + /** Ensures the specified article edition exists and raises an exception otherwise + * + * Returns an associative array containing the edition id, article id, and latest edition of the edition if it exists + * + * @param string $user The user who owns the edition to be validated + * @param integer|null $id The identifier of the edition to validate + */ protected function articleValidateEdition(string $user, int $id): array { if (!ValueInfo::id($id)) { throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "edition", 'type' => "int > 0"]); // @codeCoverageIgnore @@ -1450,6 +1551,7 @@ class Database { return array_map("intval", $out); } + /** Returns the numeric identifier of the most recent edition of an article matching the given context */ public function editionLatest(string $user, Context $context = null): int { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -1465,6 +1567,7 @@ class Database { return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); } + /** Returns a map between all the given edition identifiers and their associated article identifiers */ public function editionArticle(int ...$edition): array { $out = []; $context = (new Context)->editions($edition); @@ -1484,6 +1587,13 @@ class Database { } } + /** Creates a label, and returns its numeric identifier + * + * Labels are discrete objects in the database and can be associated with multiple articles; an article may in turn be associated with multiple labels + * + * @param string $user The user who will own the created label + * @param array $data An associative array defining the label's properties; currently only "name" is understood + */ public function labelAdd(string $user, array $data): int { // if the user isn't authorized to perform this action then throw an exception. if (!Arsse::$user->authorize($user, __FUNCTION__)) { @@ -1496,6 +1606,18 @@ class Database { return $this->db->prepare("INSERT INTO arsse_labels(owner,name) values(?,?)", "str", "str")->run($user, $name)->lastId(); } + /** Lists a user's article labels + * + * The following keys are included in each record: + * + * - "id": The label's numeric identifier + * - "name" The label's textual name + * - "articles": The count of articles which have the label assigned to them + * - "read": How many of the total articles assigned to the label are read + * + * @param string $user The user whose labels are to be listed + * @param boolean $includeEmpty Whether to include (true) or supress (false) labels which have no articles assigned to them + */ public function labelList(string $user, bool $includeEmpty = true): Db\Result { // if the user isn't authorized to perform this action then throw an exception. if (!Arsse::$user->authorize($user, __FUNCTION__)) { @@ -1518,6 +1640,14 @@ class Database { )->run($user, !$includeEmpty); } + /** Deletes a label from the database + * + * Any articles associated with the label remains untouched + * + * @param string $user The owner of the label to remove + * @param integer|string $id The numeric identifier or name of the label + * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) + */ public function labelRemove(string $user, $id, bool $byName = false): bool { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -1532,6 +1662,19 @@ class Database { return true; } + /** Retrieves the properties of a label + * + * The following keys are included in the output array: + * + * - "id": The label's numeric identifier + * - "name" The label's textual name + * - "articles": The count of articles which have the label assigned to them + * - "read": How many of the total articles assigned to the label are read + * + * @param string $user The owner of the label to remove + * @param integer|string $id The numeric identifier or name of the label + * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) + */ public function labelPropertiesGet(string $user, $id, bool $byName = false): array { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -1558,6 +1701,13 @@ class Database { return $out; } + /** Sets the properties of a label + * + * @param string $user The owner of the label to query + * @param integer|string $id The numeric identifier or name of the label + * @param array $data An associative array defining the label's properties; currently only "name" is understood + * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) + */ public function labelPropertiesSet(string $user, $id, array $data, bool $byName = false): bool { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -1583,6 +1733,12 @@ class Database { return $out; } + /** Returns an indexed array of article identifiers assigned to a label + * + * @param string $user The owner of the label to query + * @param integer|string $id The numeric identifier or name of the label + * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) + */ public function labelArticlesGet(string $user, $id, bool $byName = false): array { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -1603,6 +1759,14 @@ class Database { } } + /** Makes or breaks associations between a given label and articles matching the given query context + * + * @param string $user The owner of the label + * @param integer|string $id The numeric identifier or name of the label + * @param Context $context The query context matching the desired articles + * @param boolean $remove Whether to remove (true) rather than add (true) an association with the articles matching the context + * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) + */ public function labelArticlesSet(string $user, $id, Context $context = null, bool $remove = false, bool $byName = false): int { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -1643,6 +1807,16 @@ class Database { return $out; } + /** Ensures the specified label identifier or name is valid (and optionally whether it exists) and raises an exception otherwise + * + * Returns an associative array containing the id, name of the label if it exists + * + * @param string $user The user who owns the label to be validated + * @param integer|string $id The numeric identifier or name of the label to validate + * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) + * @param boolean $checkDb Whether to check whether the label exists (true) or only if the identifier or name is syntactically valid (false) + * @param boolean $subject Whether the label is the subject rather than the object of the operation being performed; this only affects the semantics of the error message if validation fails + */ protected function labelValidateId(string $user, $id, bool $byName, bool $checkDb = true, bool $subject = false): array { if (!$byName && !ValueInfo::id($id)) { // if we're not referring to a label by name and the ID is invalid, throw an exception @@ -1666,6 +1840,7 @@ class Database { ]; } + /** Ensures a prospective label name is syntactically valid and raises an exception otherwise */ protected function labelValidateName($name): bool { $info = ValueInfo::str($name); if ($info & (ValueInfo::NULL | ValueInfo::EMPTY)) { From b0d545836796c364603616439d607080eb5ba0d6 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 4 Feb 2019 13:18:33 -0500 Subject: [PATCH 004/142] Clarify some prospective protocols --- lib/REST.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/REST.php b/lib/REST.php index 1bc395f..39899c1 100644 --- a/lib/REST.php +++ b/lib/REST.php @@ -40,17 +40,17 @@ class REST { ], // Other candidates: // Google Reader http://feedhq.readthedocs.io/en/latest/api/index.html - // Fever https://feedafever.com/api + // Fever https://web.archive.org/web/20161217042229/https://feedafever.com/api // Feedbin v2 https://github.com/feedbin/feedbin-api // CommaFeed https://www.commafeed.com/api/ // Selfoss https://github.com/SSilence/selfoss/wiki/Restful-API-for-Apps-or-any-other-external-access + // NewsBlur http://www.newsblur.com/api // Unclear if clients exist: - // Miniflux https://github.com/miniflux/miniflux/blob/master/docs/json-rpc-api.markdown + // Miniflux https://docs.miniflux.app/en/latest/api.html#api-reference // NextCloud News v2 https://github.com/nextcloud/news/blob/master/docs/externalapi/External-Api.md // BirdReader https://github.com/glynnbird/birdreader/blob/master/API.md // Feedbin v1 https://github.com/feedbin/feedbin-api/commit/86da10aac5f1a57531a6e17b08744e5f9e7db8a9 // Proprietary (centralized) entities: - // NewsBlur http://www.newsblur.com/api // Feedly https://developer.feedly.com/ ]; const DEFAULT_PORTS = [ From 17f3a2f0599003880f8a7ac7d6b0195560d32dd7 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 13 Feb 2019 12:37:41 -0500 Subject: [PATCH 005/142] Start on an API overview for the Database class --- lib/Database.php | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/lib/Database.php b/lib/Database.php index 49ebf52..dfa9e7c 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -13,6 +13,27 @@ use JKingWeb\Arsse\Misc\Context; use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\ValueInfo; +/** The high-level interface with the database + * + * The database stores information on the following things: + * + * - Users + * - Subscriptions to feeds, which belong to users + * - Folders, which belong to users and contain subscriptions + * - Feeds to which users are subscribed + * - Articles, which belong to feeds and for which users can only affect metadata + * - Editions, identifying authorial modifications to articles + * - Labels, which belong to users and can be assigned to multiple articles + * - Sessions, used by some protocols to identify users across periods of time + * - Metadata, used internally by the server + * + * The various methods of this class perform operations on these things, with + * each public method prefixed with the thing it concerns e.g. userRemove() + * deletes a user from the database, and labelArticlesSet() changes a label's + * associations with articles. There has been an effort to keep public method + * names consistent throughout, but protected methods, having different + * concerns, will typicsally follow different conventions. + */ class Database { /** The version number of the latest schema the interface is aware of */ const SCHEMA_VERSION = 4; From 4316c700a891d00abaeb82e7ba87cde5b11166bf Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 19 Feb 2019 08:46:17 -0500 Subject: [PATCH 006/142] Nginx should send the normalized URL to the application --- dist/nginx-fcgi.conf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dist/nginx-fcgi.conf b/dist/nginx-fcgi.conf index fb37825..2890fc9 100644 --- a/dist/nginx-fcgi.conf +++ b/dist/nginx-fcgi.conf @@ -8,6 +8,6 @@ fastcgi_param SCRIPT_FILENAME /usr/share/arsse/arsse.php; fastcgi_param REQUEST_METHOD $request_method; fastcgi_param CONTENT_TYPE $content_type; fastcgi_param CONTENT_LENGTH $content_length; -fastcgi_param REQUEST_URI $request_uri; +fastcgi_param REQUEST_URI $uri; fastcgi_param HTTPS $https if_not_empty; -fastcgi_param REMOTE_USER $remote_user; \ No newline at end of file +fastcgi_param REMOTE_USER $remote_user; From b55d0b374fb0ef4313ded83039f2aa23a63c29ed Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 21 Feb 2019 15:10:32 -0500 Subject: [PATCH 007/142] API documentation for database driver interface --- lib/Db/Driver.php | 60 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/lib/Db/Driver.php b/lib/Db/Driver.php index 64eca65..d3486db 100644 --- a/lib/Db/Driver.php +++ b/lib/Db/Driver.php @@ -13,32 +13,66 @@ interface Driver { const TR_PEND_COMMIT = -1; const TR_PEND_ROLLBACK = -2; + /** Creates and returns an instance of the class; this is so that either a native or PDO driver may be returned depending on what is available on the server */ public static function create(): Driver; - // returns a human-friendly name for the driver (for display in installer, for example) + + /** Returns a human-friendly name for the driver */ public static function driverName(): string; - // returns the version of the scheme of the opened database; if uninitialized should return 0 + + /** Returns the version of the schema of the opened database; if uninitialized should return 0 + * + * Normally the version is stored under the 'schema_version' key in the arsse_meta table, but another method may be used if appropriate + */ public function schemaVersion(): int; - // returns the schema set to be used for database set-up + + /** Returns the schema set to be used for database set-up */ public static function schemaID(): string; - // return a Transaction object + + /** Returns a Transaction object */ public function begin(bool $lock = false): Transaction; - // manually begin a real or synthetic transactions, with real or synthetic nesting + + /** Manually begins a real or synthetic transactions, with real or synthetic nesting, and returns its numeric ID + * + * If the database backend does not implement savepoints, IDs must still be tracked as if it does + */ public function savepointCreate(): int; - // manually commit either the latest or all pending nested transactions + + /** Manually commits either the latest or a specified nested transaction */ public function savepointRelease(int $index = null): bool; - // manually rollback either the latest or all pending nested transactions + + /** Manually rolls back either the latest or a specified nested transaction */ public function savepointUndo(int $index = null): bool; - // attempt to perform an in-place upgrade of the database schema; this may be a no-op which always throws an exception + + /** Performs an in-place upgrade of the database schema + * + * The driver may choose not to implement in-place upgrading, in which case an exception should be thrown + */ public function schemaUpdate(int $to): bool; - // execute one or more unsanitized SQL queries and return an indication of success + + /** Executes one or more queries without parameters, returning only an indication of success */ public function exec(string $query): bool; - // perform a single unsanitized query and return a result set + + /** Executes a single query without parameters, and returns a result set */ public function query(string $query): Result; - // ready a prepared statement for later execution + + /** Readies a prepared statement for later execution */ public function prepare(string $query, ...$paramType): Statement; + + /** Readies a prepared statement for later execution */ public function prepareArray(string $query, array $paramTypes): Statement; - // report whether the database character set is correct/acceptable + + /** Reports whether the database character set is correct/acceptable + * + * The backend must be able to accept and provide UTF-8 text; information may be stored in any encoding capable of representing the entire range of Unicode + */ public function charsetAcceptable(): bool; - // return an implementation-dependent form of a reference SQL function or operator + + /** Returns an implementation-dependent form of a reference SQL function or operator + * + * The tokens the implementation must understand are: + * + * - "greatest": the GREATEST function implemented by PostgreSQL and MySQL + * - "nocase": the name of a general-purpose case-insensitive collation sequence + */ public function sqlToken(string $token): string; } From 908e1fa3105d6adedfed91584bc6beb2e13bb41d Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 21 Feb 2019 15:10:32 -0500 Subject: [PATCH 008/142] API documentation for database driver interface --- lib/Db/Driver.php | 60 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/lib/Db/Driver.php b/lib/Db/Driver.php index 64eca65..d3486db 100644 --- a/lib/Db/Driver.php +++ b/lib/Db/Driver.php @@ -13,32 +13,66 @@ interface Driver { const TR_PEND_COMMIT = -1; const TR_PEND_ROLLBACK = -2; + /** Creates and returns an instance of the class; this is so that either a native or PDO driver may be returned depending on what is available on the server */ public static function create(): Driver; - // returns a human-friendly name for the driver (for display in installer, for example) + + /** Returns a human-friendly name for the driver */ public static function driverName(): string; - // returns the version of the scheme of the opened database; if uninitialized should return 0 + + /** Returns the version of the schema of the opened database; if uninitialized should return 0 + * + * Normally the version is stored under the 'schema_version' key in the arsse_meta table, but another method may be used if appropriate + */ public function schemaVersion(): int; - // returns the schema set to be used for database set-up + + /** Returns the schema set to be used for database set-up */ public static function schemaID(): string; - // return a Transaction object + + /** Returns a Transaction object */ public function begin(bool $lock = false): Transaction; - // manually begin a real or synthetic transactions, with real or synthetic nesting + + /** Manually begins a real or synthetic transactions, with real or synthetic nesting, and returns its numeric ID + * + * If the database backend does not implement savepoints, IDs must still be tracked as if it does + */ public function savepointCreate(): int; - // manually commit either the latest or all pending nested transactions + + /** Manually commits either the latest or a specified nested transaction */ public function savepointRelease(int $index = null): bool; - // manually rollback either the latest or all pending nested transactions + + /** Manually rolls back either the latest or a specified nested transaction */ public function savepointUndo(int $index = null): bool; - // attempt to perform an in-place upgrade of the database schema; this may be a no-op which always throws an exception + + /** Performs an in-place upgrade of the database schema + * + * The driver may choose not to implement in-place upgrading, in which case an exception should be thrown + */ public function schemaUpdate(int $to): bool; - // execute one or more unsanitized SQL queries and return an indication of success + + /** Executes one or more queries without parameters, returning only an indication of success */ public function exec(string $query): bool; - // perform a single unsanitized query and return a result set + + /** Executes a single query without parameters, and returns a result set */ public function query(string $query): Result; - // ready a prepared statement for later execution + + /** Readies a prepared statement for later execution */ public function prepare(string $query, ...$paramType): Statement; + + /** Readies a prepared statement for later execution */ public function prepareArray(string $query, array $paramTypes): Statement; - // report whether the database character set is correct/acceptable + + /** Reports whether the database character set is correct/acceptable + * + * The backend must be able to accept and provide UTF-8 text; information may be stored in any encoding capable of representing the entire range of Unicode + */ public function charsetAcceptable(): bool; - // return an implementation-dependent form of a reference SQL function or operator + + /** Returns an implementation-dependent form of a reference SQL function or operator + * + * The tokens the implementation must understand are: + * + * - "greatest": the GREATEST function implemented by PostgreSQL and MySQL + * - "nocase": the name of a general-purpose case-insensitive collation sequence + */ public function sqlToken(string $token): string; } From ad8057a40b74b09808d0166e5b778d5978a92d3e Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 22 Feb 2019 11:13:13 -0500 Subject: [PATCH 009/142] Driver changes to support basic text searching --- lib/Db/Driver.php | 4 ++++ lib/Db/MySQL/Driver.php | 4 ++++ lib/Db/PostgreSQL/Driver.php | 6 ++++++ lib/Db/SQLite3/Driver.php | 4 ++++ 4 files changed, 18 insertions(+) diff --git a/lib/Db/Driver.php b/lib/Db/Driver.php index d3486db..81241e5 100644 --- a/lib/Db/Driver.php +++ b/lib/Db/Driver.php @@ -73,6 +73,10 @@ interface Driver { * * - "greatest": the GREATEST function implemented by PostgreSQL and MySQL * - "nocase": the name of a general-purpose case-insensitive collation sequence + * - "like": the case-insensitive LIKE operator */ public function sqlToken(string $token): string; + + /** Indicates whether the implementation is capable of full-text searching */ + public function fulltextEnabled(): bool; } diff --git a/lib/Db/MySQL/Driver.php b/lib/Db/MySQL/Driver.php index 8a4fe44..ac3fa79 100644 --- a/lib/Db/MySQL/Driver.php +++ b/lib/Db/MySQL/Driver.php @@ -212,4 +212,8 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement { return new Statement($this->db, $query, $paramTypes, $this->packetSize); } + + public function fulltextEnabled(): bool { + return false; + } } diff --git a/lib/Db/PostgreSQL/Driver.php b/lib/Db/PostgreSQL/Driver.php index 513ce99..e138afa 100644 --- a/lib/Db/PostgreSQL/Driver.php +++ b/lib/Db/PostgreSQL/Driver.php @@ -120,6 +120,8 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { switch (strtolower($token)) { case "nocase": return '"und-x-icu"'; + case "like": + return "ilike"; default: return $token; } @@ -219,4 +221,8 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement { return new Statement($this->db, $query, $paramTypes); } + + public function fulltextEnabled(): bool { + return false; + } } diff --git a/lib/Db/SQLite3/Driver.php b/lib/Db/SQLite3/Driver.php index f7e47fb..e979d7c 100644 --- a/lib/Db/SQLite3/Driver.php +++ b/lib/Db/SQLite3/Driver.php @@ -179,4 +179,8 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { $this->exec((!$rollback) ? "COMMIT" : "ROLLBACK"); return true; } + + public function fulltextEnabled(): bool { + return false; + } } From f9fde2370888cc38b003da0ba0c609e527d0e28f Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 22 Feb 2019 11:13:42 -0500 Subject: [PATCH 010/142] Context changes to support basic text searching --- lib/Misc/Context.php | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/lib/Misc/Context.php b/lib/Misc/Context.php index 93e4ac4..fa6241a 100644 --- a/lib/Misc/Context.php +++ b/lib/Misc/Context.php @@ -34,6 +34,7 @@ class Context { public $labelName; public $labelled = null; public $annotated = null; + public $searchTerms = []; protected $props = []; @@ -52,7 +53,7 @@ class Context { } } - protected function cleanArray(array $spec): array { + protected function cleanIdArray(array $spec): array { $spec = array_values($spec); for ($a = 0; $a < sizeof($spec); $a++) { if (ValueInfo::id($spec[$a])) { @@ -64,6 +65,18 @@ class Context { return array_values(array_filter($spec)); } + protected function cleanStringArray(array $spec): array { + $spec = array_values($spec); + for ($a = 0; $a < sizeof($spec); $a++) { + if (strlen($str = ValueInfo::normalize($spec[$a], ValueInfo::T_STRING))) { + $spec[$a] = $str; + } else { + unset($spec[$a]); + } + } + return array_values($spec); + } + public function reverse(bool $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } @@ -142,14 +155,14 @@ class Context { public function editions(array $spec = null) { if (isset($spec)) { - $spec = $this->cleanArray($spec); + $spec = $this->cleanIdArray($spec); } return $this->act(__FUNCTION__, func_num_args(), $spec); } public function articles(array $spec = null) { if (isset($spec)) { - $spec = $this->cleanArray($spec); + $spec = $this->cleanIdArray($spec); } return $this->act(__FUNCTION__, func_num_args(), $spec); } @@ -169,4 +182,11 @@ class Context { public function annotated(bool $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } + + public function searchTerms(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanStringArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } } From ace94e3ef85e76cffb1f2e37e703532f443e3df1 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 22 Feb 2019 12:34:06 -0500 Subject: [PATCH 011/142] Fix context, and context tests --- lib/Misc/Context.php | 7 ++++--- tests/cases/Misc/TestContext.php | 15 ++++++++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/lib/Misc/Context.php b/lib/Misc/Context.php index fa6241a..87ada39 100644 --- a/lib/Misc/Context.php +++ b/lib/Misc/Context.php @@ -34,7 +34,7 @@ class Context { public $labelName; public $labelled = null; public $annotated = null; - public $searchTerms = []; + public $searchTerms = null; protected $props = []; @@ -67,8 +67,9 @@ class Context { protected function cleanStringArray(array $spec): array { $spec = array_values($spec); - for ($a = 0; $a < sizeof($spec); $a++) { - if (strlen($str = ValueInfo::normalize($spec[$a], ValueInfo::T_STRING))) { + $stop = sizeof($spec); + for ($a = 0; $a < $stop; $a++) { + if (strlen($str = ValueInfo::normalize($spec[$a], ValueInfo::T_STRING | ValueInfo::M_DROP) ?? "")) { $spec[$a] = $str; } else { unset($spec[$a]); diff --git a/tests/cases/Misc/TestContext.php b/tests/cases/Misc/TestContext.php index 07d6adb..b767d11 100644 --- a/tests/cases/Misc/TestContext.php +++ b/tests/cases/Misc/TestContext.php @@ -7,6 +7,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\Misc; use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Misc\ValueInfo; /** @covers \JKingWeb\Arsse\Misc\Context */ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { @@ -48,6 +49,7 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { 'labelName' => "Rush", 'labelled' => true, 'annotated' => true, + 'searchTerms' => ["foo", "bar"], ]; $times = ['modifiedSince','notModifiedSince','markedSince','notMarkedSince']; $c = new Context; @@ -70,7 +72,7 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { } } - public function testCleanArrayValues() { + public function testCleanIdArrayValues() { $methods = ["articles", "editions"]; $in = [1, "2", 3.5, 3.0, "ook", 0, -20, true, false, null, new \DateTime(), -1.0]; $out = [1,2, 3]; @@ -79,4 +81,15 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { $this->assertSame($out, $c->$method($in)->$method, "Context method $method did not return the expected results"); } } + + public function testCleanStringArrayValues() { + $methods = ["searchTerms"]; + $now = new \DateTime; + $in = [1, 3.0, "ook", 0, true, false, null, $now, ""]; + $out = ["1", "3", "ook", "0", valueInfo::normalize($now, ValueInfo::T_STRING)]; + $c = new Context; + foreach ($methods as $method) { + $this->assertSame($out, $c->$method($in)->$method, "Context method $method did not return the expected results"); + } + } } From 570a9b171cba166fb487d16d6eaa1c619eac97e3 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 22 Feb 2019 18:49:57 -0500 Subject: [PATCH 012/142] Revert fulltext detection in driver --- lib/Db/Driver.php | 3 --- lib/Db/MySQL/Driver.php | 4 ---- lib/Db/PostgreSQL/Driver.php | 4 ---- lib/Db/SQLite3/Driver.php | 4 ---- 4 files changed, 15 deletions(-) diff --git a/lib/Db/Driver.php b/lib/Db/Driver.php index 81241e5..959a550 100644 --- a/lib/Db/Driver.php +++ b/lib/Db/Driver.php @@ -76,7 +76,4 @@ interface Driver { * - "like": the case-insensitive LIKE operator */ public function sqlToken(string $token): string; - - /** Indicates whether the implementation is capable of full-text searching */ - public function fulltextEnabled(): bool; } diff --git a/lib/Db/MySQL/Driver.php b/lib/Db/MySQL/Driver.php index ac3fa79..8a4fe44 100644 --- a/lib/Db/MySQL/Driver.php +++ b/lib/Db/MySQL/Driver.php @@ -212,8 +212,4 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement { return new Statement($this->db, $query, $paramTypes, $this->packetSize); } - - public function fulltextEnabled(): bool { - return false; - } } diff --git a/lib/Db/PostgreSQL/Driver.php b/lib/Db/PostgreSQL/Driver.php index e138afa..08c439d 100644 --- a/lib/Db/PostgreSQL/Driver.php +++ b/lib/Db/PostgreSQL/Driver.php @@ -221,8 +221,4 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement { return new Statement($this->db, $query, $paramTypes); } - - public function fulltextEnabled(): bool { - return false; - } } diff --git a/lib/Db/SQLite3/Driver.php b/lib/Db/SQLite3/Driver.php index e979d7c..f7e47fb 100644 --- a/lib/Db/SQLite3/Driver.php +++ b/lib/Db/SQLite3/Driver.php @@ -179,8 +179,4 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { $this->exec((!$rollback) ? "COMMIT" : "ROLLBACK"); return true; } - - public function fulltextEnabled(): bool { - return false; - } } From bc3182a961959485867c154459d9f9554f716cf5 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 22 Feb 2019 18:50:39 -0500 Subject: [PATCH 013/142] Basic substring searching --- lib/Database.php | 42 +++++++++++++++++++++++++- tests/cases/Database/SeriesArticle.php | 19 ++++++++++-- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index dfa9e7c..30562d9 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -39,6 +39,8 @@ class Database { const SCHEMA_VERSION = 4; /** The maximum number of articles to mark in one query without chunking */ const LIMIT_ARTICLES = 50; + /** The maximum number of search terms allowed; this is a hard limit */ + const LIMIT_TERMS = 100; /** A map database driver short-names and their associated class names */ const DRIVER_NAMES = [ 'sqlite3' => \JKingWeb\Arsse\Db\SQLite3\Driver::class, @@ -149,6 +151,35 @@ class Database { return $out; } + /** Computes basic LIKE-based text search constraints for use in a WHERE clause + * + * Returns an indexed array containing the clause text, an array of types, and another array of values + * + * The clause is structured such that all terms must be present across any of the columns + * + * @param string[] $terms The terms to search for + * @param string[] $cols The columns to match against; these are -not- sanitized, so much -not- come directly from user input + */ + protected function generateSearch(array $terms, array $cols): array { + $clause = []; + $types = []; + $values = []; + $like = $this->db->sqlToken("like"); + foreach($terms as $term) { + $term = str_replace(["%", "_", "^"], ["^%", "^_", "^^"], $term); + $term = "%$term%"; + $spec = []; + foreach ($cols as $col) { + $spec[] = "$col $like ? escape '^'"; + $types[] = "str"; + $values[] = $term; + } + $clause[] = "(".implode(" or ", $spec).")"; + } + $clause = "(".implode(" and ", $clause).")"; + return [$clause, $types, $values]; + } + /** Returns a Transaction object, which is rolled back unless explicitly committed */ public function begin(): Db\Transaction { return $this->db->begin(); @@ -1160,7 +1191,7 @@ class Database { list($inParams, $inTypes) = $this->generateIn($context->editions, "int"); $q->setWhere("latest_editions.edition in ($inParams)", $inTypes, $context->editions); } elseif ($context->articles()) { - // if multiple specific articles have been requested, prepare a CTE to list them and their articles + // if multiple specific articles have been requested, filter against the list if (!$context->articles) { throw new Db\ExceptionInput("tooShort", ['field' => "articles", 'action' => __FUNCTION__, 'min' => 1]); // must have at least one array element } elseif (sizeof($context->articles) > self::LIMIT_ARTICLES) { @@ -1221,6 +1252,15 @@ class Database { $comp = ($context->annotated) ? "<>" : "="; $q->setWhere("coalesce(arsse_marks.note,'') $comp ''"); } + // filter based on search terms + if ($context->searchTerms()) { + if (!$context->searchTerms) { + throw new Db\ExceptionInput("tooShort", ['field' => "searchTerms", 'action' => __FUNCTION__, 'min' => 1]); // must have at least one array element + } elseif (sizeof($context->searchTerms) > self::LIMIT_TERMS) { + throw new Db\ExceptionInput("tooLong", ['field' => "searchTerms", 'action' => __FUNCTION__, 'max' => self::LIMIT_TERMS]); + } + $q->setWhere(...$this->generateSearch($context->searchTerms, ["arsse_articles.title", "arsse_articles.content"])); + } // return the query return $q; } diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index c3c4425..d19f85b 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -111,9 +111,9 @@ trait SeriesArticle { 'modified' => "datetime", ], 'rows' => [ - [1,1,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], - [2,1,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], - [3,2,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [1,1,null,"Title one", null,null,null,"First article", null,"","","","2000-01-01T00:00:00Z"], + [2,1,null,"Title two", null,null,null,"Second article",null,"","","","2010-01-01T00:00:00Z"], + [3,2,null,"Title three",null,null,null,"Third article", null,"","","","2000-01-01T00:00:00Z"], [4,2,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], [5,3,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], [6,3,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], @@ -494,6 +494,9 @@ trait SeriesArticle { // get specific starred articles $compareIds([1], (new Context)->articles([1,2,3])->starred(true)); $compareIds([2,3], (new Context)->articles([1,2,3])->starred(false)); + // get items that match search terms + $compareIds([1,2,3], (new Context)->searchTerms(["Article"])); + $compareIds([1], (new Context)->searchTerms(["one", "first"])); } public function testListArticlesOfAMissingFolder() { @@ -985,4 +988,14 @@ trait SeriesArticle { $this->assertException("notAuthorized", "User", "ExceptionAuthz"); Arsse::$db->articleCategoriesGet($this->user, 19); } + + public function testSearchTooFewTerms() { + $this->assertException("tooShort", "Db", "ExceptionInput"); + Arsse::$db->articleList($this->user, (new Context)->searchTerms([])); + } + + public function testSearchTooManyTerms() { + $this->assertException("tooLong", "Db", "ExceptionInput"); + Arsse::$db->articleList($this->user, (new Context)->searchTerms(range(1, 105))); + } } From 2df7c25b663d8919a386eedf7c81a55c88e4afbf Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 23 Feb 2019 20:14:52 -0500 Subject: [PATCH 014/142] Add ability to search note text --- lib/Database.php | 21 +++++++++++++++------ lib/Misc/Context.php | 8 ++++++++ tests/cases/Database/SeriesArticle.php | 15 +++++++++++++++ tests/cases/Db/BaseDriver.php | 1 + tests/cases/Misc/TestContext.php | 3 ++- 5 files changed, 41 insertions(+), 7 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 30562d9..0310c5a 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1184,18 +1184,18 @@ class Database { if ($context->editions()) { // if multiple specific editions have been requested, filter against the list if (!$context->editions) { - throw new Db\ExceptionInput("tooShort", ['field' => "editions", 'action' => __FUNCTION__, 'min' => 1]); // must have at least one array element + throw new Db\ExceptionInput("tooShort", ['field' => "editions", 'action' => $this->caller(), 'min' => 1]); // must have at least one array element } elseif (sizeof($context->editions) > self::LIMIT_ARTICLES) { - throw new Db\ExceptionInput("tooLong", ['field' => "editions", 'action' => __FUNCTION__, 'max' => self::LIMIT_ARTICLES]); // @codeCoverageIgnore + throw new Db\ExceptionInput("tooLong", ['field' => "editions", 'action' => $this->caller(), 'max' => self::LIMIT_ARTICLES]); // @codeCoverageIgnore } list($inParams, $inTypes) = $this->generateIn($context->editions, "int"); $q->setWhere("latest_editions.edition in ($inParams)", $inTypes, $context->editions); } elseif ($context->articles()) { // if multiple specific articles have been requested, filter against the list if (!$context->articles) { - throw new Db\ExceptionInput("tooShort", ['field' => "articles", 'action' => __FUNCTION__, 'min' => 1]); // must have at least one array element + throw new Db\ExceptionInput("tooShort", ['field' => "articles", 'action' => $this->caller(), 'min' => 1]); // must have at least one array element } elseif (sizeof($context->articles) > self::LIMIT_ARTICLES) { - throw new Db\ExceptionInput("tooLong", ['field' => "articles", 'action' => __FUNCTION__, 'max' => self::LIMIT_ARTICLES]); // @codeCoverageIgnore + throw new Db\ExceptionInput("tooLong", ['field' => "articles", 'action' => $this->caller(), 'max' => self::LIMIT_ARTICLES]); // @codeCoverageIgnore } list($inParams, $inTypes) = $this->generateIn($context->articles, "int"); $q->setWhere("arsse_articles.id in ($inParams)", $inTypes, $context->articles); @@ -1255,12 +1255,21 @@ class Database { // filter based on search terms if ($context->searchTerms()) { if (!$context->searchTerms) { - throw new Db\ExceptionInput("tooShort", ['field' => "searchTerms", 'action' => __FUNCTION__, 'min' => 1]); // must have at least one array element + throw new Db\ExceptionInput("tooShort", ['field' => "searchTerms", 'action' => $this->caller(), 'min' => 1]); // must have at least one array element } elseif (sizeof($context->searchTerms) > self::LIMIT_TERMS) { - throw new Db\ExceptionInput("tooLong", ['field' => "searchTerms", 'action' => __FUNCTION__, 'max' => self::LIMIT_TERMS]); + throw new Db\ExceptionInput("tooLong", ['field' => "searchTerms", 'action' => $this->caller(), 'max' => self::LIMIT_TERMS]); } $q->setWhere(...$this->generateSearch($context->searchTerms, ["arsse_articles.title", "arsse_articles.content"])); } + // filter based on search terms in note + if ($context->annotationTerms()) { + if (!$context->annotationTerms) { + throw new Db\ExceptionInput("tooShort", ['field' => "annotationTerms", 'action' => $this->caller(), 'min' => 1]); // must have at least one array element + } elseif (sizeof($context->annotationTerms) > self::LIMIT_TERMS) { + throw new Db\ExceptionInput("tooLong", ['field' => "annotationTerms", 'action' => $this->caller(), 'max' => self::LIMIT_TERMS]); + } + $q->setWhere(...$this->generateSearch($context->annotationTerms, ["arsse_marks.note"])); + } // return the query return $q; } diff --git a/lib/Misc/Context.php b/lib/Misc/Context.php index 87ada39..1dd1a17 100644 --- a/lib/Misc/Context.php +++ b/lib/Misc/Context.php @@ -34,6 +34,7 @@ class Context { public $labelName; public $labelled = null; public $annotated = null; + public $annotationTerms = null; public $searchTerms = null; protected $props = []; @@ -184,6 +185,13 @@ class Context { return $this->act(__FUNCTION__, func_num_args(), $spec); } + public function annotationTerms(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanStringArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + public function searchTerms(array $spec = null) { if (isset($spec)) { $spec = $this->cleanStringArray($spec); diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index d19f85b..80114c4 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -497,6 +497,11 @@ trait SeriesArticle { // get items that match search terms $compareIds([1,2,3], (new Context)->searchTerms(["Article"])); $compareIds([1], (new Context)->searchTerms(["one", "first"])); + // get items that match search terms in note + $compareIds([2], (new Context)->annotationTerms(["some"])); + $compareIds([2], (new Context)->annotationTerms(["some", "note"])); + $compareIds([2], (new Context)->annotationTerms(["some note"])); + $compareIds([], (new Context)->annotationTerms(["some", "sauce"])); } public function testListArticlesOfAMissingFolder() { @@ -998,4 +1003,14 @@ trait SeriesArticle { $this->assertException("tooLong", "Db", "ExceptionInput"); Arsse::$db->articleList($this->user, (new Context)->searchTerms(range(1, 105))); } + + public function testSearchTooFewTermsInNote() { + $this->assertException("tooShort", "Db", "ExceptionInput"); + Arsse::$db->articleList($this->user, (new Context)->annotationTerms([])); + } + + public function testSearchTooManyTermsInNote() { + $this->assertException("tooLong", "Db", "ExceptionInput"); + Arsse::$db->articleList($this->user, (new Context)->annotationTerms(range(1, 105))); + } } diff --git a/tests/cases/Db/BaseDriver.php b/tests/cases/Db/BaseDriver.php index 682c688..4967e84 100644 --- a/tests/cases/Db/BaseDriver.php +++ b/tests/cases/Db/BaseDriver.php @@ -94,6 +94,7 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest { public function testTranslateAToken() { $this->assertRegExp("/^[a-z][a-z0-9]*$/i", $this->drv->sqlToken("greatest")); $this->assertRegExp("/^\"?[a-z][a-z0-9_\-]*\"?$/i", $this->drv->sqlToken("nocase")); + $this->assertRegExp("/^[a-z][a-z0-9]*$/i", $this->drv->sqlToken("like")); $this->assertSame("distinct", $this->drv->sqlToken("distinct")); } diff --git a/tests/cases/Misc/TestContext.php b/tests/cases/Misc/TestContext.php index b767d11..902a6ba 100644 --- a/tests/cases/Misc/TestContext.php +++ b/tests/cases/Misc/TestContext.php @@ -50,6 +50,7 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { 'labelled' => true, 'annotated' => true, 'searchTerms' => ["foo", "bar"], + 'annotationTerms' => ["foo", "bar"], ]; $times = ['modifiedSince','notModifiedSince','markedSince','notMarkedSince']; $c = new Context; @@ -83,7 +84,7 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { } public function testCleanStringArrayValues() { - $methods = ["searchTerms"]; + $methods = ["searchTerms", "annotationTerms"]; $now = new \DateTime; $in = [1, 3.0, "ook", 0, true, false, null, $now, ""]; $out = ["1", "3", "ook", "0", valueInfo::normalize($now, ValueInfo::T_STRING)]; From f4a74eec5d3680c017cbe935488869e748e787bb Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Feb 2019 10:46:43 -0500 Subject: [PATCH 015/142] Add all the other context options allowed by the TTRSS search syntax --- lib/Misc/Context.php | 26 ++++++++++++++++++++++++++ tests/cases/Misc/TestContext.php | 9 ++++++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/lib/Misc/Context.php b/lib/Misc/Context.php index 1dd1a17..9263fa1 100644 --- a/lib/Misc/Context.php +++ b/lib/Misc/Context.php @@ -10,6 +10,7 @@ use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\ValueInfo; class Context { + public $not = null; public $reverse = false; public $limit = 0; public $offset = 0; @@ -36,9 +37,16 @@ class Context { public $annotated = null; public $annotationTerms = null; public $searchTerms = null; + public $titleTerms = null; + public $authorTerms = null; protected $props = []; + public function __clone() { + // clone the negation context, if any + $this->not = $this->not ? clone $this->not : null; + } + protected function act(string $prop, int $set, $value) { if ($set) { if (is_null($value)) { @@ -198,4 +206,22 @@ class Context { } return $this->act(__FUNCTION__, func_num_args(), $spec); } + + public function titleTerms(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanStringArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function authorTerms(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanStringArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function not(self $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } } diff --git a/tests/cases/Misc/TestContext.php b/tests/cases/Misc/TestContext.php index 902a6ba..12a9969 100644 --- a/tests/cases/Misc/TestContext.php +++ b/tests/cases/Misc/TestContext.php @@ -14,7 +14,7 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { public function testVerifyInitialState() { $c = new Context; foreach ((new \ReflectionObject($c))->getMethods(\ReflectionMethod::IS_PUBLIC) as $m) { - if ($m->isConstructor() || $m->isStatic()) { + if ($m->isStatic() || strpos($m->name, "__") === 0) { continue; } $method = $m->name; @@ -51,11 +51,14 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { 'annotated' => true, 'searchTerms' => ["foo", "bar"], 'annotationTerms' => ["foo", "bar"], + 'titleTerms' => ["foo", "bar"], + 'authorTerms' => ["foo", "bar"], + 'not' => (new Context)->subscription(5), ]; $times = ['modifiedSince','notModifiedSince','markedSince','notMarkedSince']; $c = new Context; foreach ((new \ReflectionObject($c))->getMethods(\ReflectionMethod::IS_PUBLIC) as $m) { - if ($m->isConstructor() || $m->isStatic()) { + if ($m->isStatic() || strpos($m->name, "__") === 0) { continue; } $method = $m->name; @@ -84,7 +87,7 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { } public function testCleanStringArrayValues() { - $methods = ["searchTerms", "annotationTerms"]; + $methods = ["searchTerms", "annotationTerms", "titleTerms", "authorTerms"]; $now = new \DateTime; $in = [1, 3.0, "ook", 0, true, false, null, $now, ""]; $out = ["1", "3", "ook", "0", valueInfo::normalize($now, ValueInfo::T_STRING)]; From 14c02d56ac36f1aaea8a7b2835da7fab112af12c Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Feb 2019 16:26:38 -0500 Subject: [PATCH 016/142] Implement new context options other than not(). Context handling has also been re-organized to simplify later implementation of the not() option --- lib/Database.php | 195 +++++++++++-------------- tests/cases/Database/Base.php | 4 +- tests/cases/Database/SeriesArticle.php | 166 ++++++++++----------- 3 files changed, 163 insertions(+), 202 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 0310c5a..353e7b9 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -39,8 +39,6 @@ class Database { const SCHEMA_VERSION = 4; /** The maximum number of articles to mark in one query without chunking */ const LIMIT_ARTICLES = 50; - /** The maximum number of search terms allowed; this is a hard limit */ - const LIMIT_TERMS = 100; /** A map database driver short-names and their associated class names */ const DRIVER_NAMES = [ 'sqlite3' => \JKingWeb\Arsse\Db\SQLite3\Driver::class, @@ -129,7 +127,7 @@ class Database { /** Conputes the contents of an SQL "IN()" clause, producing one parameter placeholder for each input value * - * Returns an indexed array containing the clause text, and an array of types + * Returns an indexed array containing the clause text, an array of types, and the array of values * * @param array $values Arbitrary values * @param string $type A single data type applied to each value @@ -138,6 +136,7 @@ class Database { $out = [ "", // query clause [], // binding types + $values, // binding values ]; if (sizeof($values)) { // the query clause is just a series of question marks separated by commas @@ -1096,8 +1095,32 @@ class Database { * @param array $cols The columns to request in the result set */ protected function articleQuery(string $user, Context $context, array $cols = ["id"]): Query { + // validate input + if ($context->subscription()) { + $this->subscriptionValidateId($user, $context->subscription); + } + if ($context->folder()) { + $this->folderValidateId($user, $context->folder); + } + if ($context->folderShallow()) { + $this->folderValidateId($user, $context->folderShallow); + } + if ($context->edition()) { + $this->articleValidateEdition($user, $context->edition); + } + if ($context->article()) { + $this->articleValidateId($user, $context->article); + } + if ($context->label()) { + $this->labelValidateId($user, $context->label, false); + } + if ($context->labelName()) { + // dereference the label name to an ID + $context->label((int) $this->labelValidateId($user, $context->labelName, true)['id']); + $context->labelName(null); + } + // prepare the output column list; the column definitions are also used later $greatest = $this->db->sqlToken("greatest"); - // prepare the output column list $colDefs = [ 'id' => "arsse_articles.id", 'edition' => "latest_editions.edition", @@ -1107,6 +1130,7 @@ class Database { 'content' => "arsse_articles.content", 'guid' => "arsse_articles.guid", 'fingerprint' => "arsse_articles.url_title_hash || ':' || arsse_articles.url_content_hash || ':' || arsse_articles.title_content_hash", + 'folder' => "coalesce(arsse_subscriptions.folder,0)", 'subscription' => "arsse_subscriptions.id", 'feed' => "arsse_subscriptions.feed", 'starred' => "coalesce(arsse_marks.starred,0)", @@ -1148,127 +1172,82 @@ class Database { ["str"], [$user] ); + $q->setLimit($context->limit, $context->offset); $q->setCTE("latest_editions(article,edition)", "SELECT article,max(id) from arsse_editions group by article", [], [], "join latest_editions on arsse_articles.id = latest_editions.article"); if ($cols) { // if there are no output columns requested we're getting a count and should not group, but otherwise we should $q->setGroup("arsse_articles.id", "arsse_marks.note", "arsse_enclosures.url", "arsse_enclosures.type", "arsse_subscriptions.title", "arsse_feeds.title", "arsse_subscriptions.id", "arsse_marks.modified", "arsse_label_members.modified", "arsse_marks.read", "arsse_marks.starred", "latest_editions.edition"); } - $q->setLimit($context->limit, $context->offset); - if ($context->subscription()) { - // if a subscription is specified, make sure it exists - $this->subscriptionValidateId($user, $context->subscription); - // filter for the subscription - $q->setWhere("arsse_subscriptions.id = ?", "int", $context->subscription); - } elseif ($context->folder()) { - // if a folder is specified, make sure it exists - $this->folderValidateId($user, $context->folder); - // if it does exist, add a common table expression to list it and its children so that we select from the entire subtree - $q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on parent = folder", "int", $context->folder); - // limit subscriptions to the listed folders - $q->setWhere("arsse_subscriptions.folder in (select folder from folders)"); - } elseif ($context->folderShallow()) { - // if a shallow folder is specified, make sure it exists - $this->folderValidateId($user, $context->folderShallow); - // if it does exist, filter for that folder only - $q->setWhere("coalesce(arsse_subscriptions.folder,0) = ?", "int", $context->folderShallow); - } - if ($context->edition()) { - // if an edition is specified, first validate it, then filter for it - $this->articleValidateEdition($user, $context->edition); - $q->setWhere("latest_editions.edition = ?", "int", $context->edition); - } elseif ($context->article()) { - // if an article is specified, first validate it, then filter for it - $this->articleValidateId($user, $context->article); - $q->setWhere("arsse_articles.id = ?", "int", $context->article); - } - if ($context->editions()) { - // if multiple specific editions have been requested, filter against the list - if (!$context->editions) { - throw new Db\ExceptionInput("tooShort", ['field' => "editions", 'action' => $this->caller(), 'min' => 1]); // must have at least one array element - } elseif (sizeof($context->editions) > self::LIMIT_ARTICLES) { - throw new Db\ExceptionInput("tooLong", ['field' => "editions", 'action' => $this->caller(), 'max' => self::LIMIT_ARTICLES]); // @codeCoverageIgnore - } - list($inParams, $inTypes) = $this->generateIn($context->editions, "int"); - $q->setWhere("latest_editions.edition in ($inParams)", $inTypes, $context->editions); - } elseif ($context->articles()) { - // if multiple specific articles have been requested, filter against the list - if (!$context->articles) { - throw new Db\ExceptionInput("tooShort", ['field' => "articles", 'action' => $this->caller(), 'min' => 1]); // must have at least one array element - } elseif (sizeof($context->articles) > self::LIMIT_ARTICLES) { - throw new Db\ExceptionInput("tooLong", ['field' => "articles", 'action' => $this->caller(), 'max' => self::LIMIT_ARTICLES]); // @codeCoverageIgnore + // handle the simple context options + foreach ([ + // each context array consists of a column identifier (see $colDefs above), a comparison operator, a data type, and an upper bound if the value is an array + "edition" => ["edition", "=", "int", 1], + "editions" => ["edition", "in", "int", self::LIMIT_ARTICLES], + "article" => ["id", "=", "int", 1], + "articles" => ["id", "in", "int", self::LIMIT_ARTICLES], + "oldestArticle" => ["id", ">=", "int", 1], + "latestArticle" => ["id", "<=", "int", 1], + "oldestEdition" => ["edition", ">=", "int", 1], + "latestEdition" => ["edition", "<=", "int", 1], + "modifiedSince" => ["modified_date", ">=", "datetime", 1], + "notModifiedSince" => ["modified_date", "<=", "datetime", 1], + "markedSince" => ["marked_date", ">=", "datetime", 1], + "notMarkedSince" => ["marked_date", "<=", "datetime", 1], + "folderShallow" => ["folder", "=", "int", 1], + "subscription" => ["subscription", "=", "int", 1], + "unread" => ["unread", "=", "bool", 1], + "starred" => ["starred", "=", "bool", 1], + ] as $m => list($col, $op, $type, $max)) { + if (!$context->$m()) { + // context is not being used + continue; + } elseif (is_array($context->$m)) { + if (!$context->$m) { + throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element + } elseif (sizeof($context->$m) > $max) { + throw new Db\ExceptionInput("tooLong", ['field' => $m, 'action' => $this->caller(), 'max' => $max]); // @codeCoverageIgnore + } + list($clause, $types, $values) = $this->generateIn($context->$m, $type); + $q->setWhere("{$colDefs[$col]} $op ($clause)", $types, $values); + } else { + $q->setWhere("{$colDefs[$col]} $op ?", $type, $context->$m); } - list($inParams, $inTypes) = $this->generateIn($context->articles, "int"); - $q->setWhere("arsse_articles.id in ($inParams)", $inTypes, $context->articles); } - // filter based on label by ID or name + // handle complex context options if ($context->labelled()) { // any label (true) or no label (false) $isOrIsNot = (!$context->labelled ? "is" : "is not"); $q->setWhere("arsse_labels.id $isOrIsNot null"); - } elseif ($context->label() || $context->labelName()) { - // specific label ID or name - if ($context->label()) { - $id = $this->labelValidateId($user, $context->label, false)['id']; - } else { - $id = $this->labelValidateId($user, $context->labelName, true)['id']; - } - $q->setWhere("arsse_labels.id = ?", "int", $id); - } - // filter based on article or edition offset - if ($context->oldestArticle()) { - $q->setWhere("arsse_articles.id >= ?", "int", $context->oldestArticle); - } - if ($context->latestArticle()) { - $q->setWhere("arsse_articles.id <= ?", "int", $context->latestArticle); - } - if ($context->oldestEdition()) { - $q->setWhere("latest_editions.edition >= ?", "int", $context->oldestEdition); } - if ($context->latestEdition()) { - $q->setWhere("latest_editions.edition <= ?", "int", $context->latestEdition); + if ($context->label()) { + // label ID (label names are dereferenced during input validation above) + $q->setWhere("arsse_labels.id = ?", "int", $context->label); } - // filter based on time at which an article was changed by feed updates (modified), or by user action (marked) - if ($context->modifiedSince()) { - $q->setWhere("arsse_articles.modified >= ?", "datetime", $context->modifiedSince); - } - if ($context->notModifiedSince()) { - $q->setWhere("arsse_articles.modified <= ?", "datetime", $context->notModifiedSince); - } - if ($context->markedSince()) { - $q->setWhere($colDefs['marked_date']." >= ?", "datetime", $context->markedSince); - } - if ($context->notMarkedSince()) { - $q->setWhere($colDefs['marked_date']." <= ?", "datetime", $context->notMarkedSince); - } - // filter for un/read and un/starred status if specified - if ($context->unread()) { - $q->setWhere("coalesce(arsse_marks.read,0) = ?", "bool", !$context->unread); - } - if ($context->starred()) { - $q->setWhere("coalesce(arsse_marks.starred,0) = ?", "bool", $context->starred); - } - // filter based on whether the article has a note if ($context->annotated()) { $comp = ($context->annotated) ? "<>" : "="; $q->setWhere("coalesce(arsse_marks.note,'') $comp ''"); } - // filter based on search terms - if ($context->searchTerms()) { - if (!$context->searchTerms) { - throw new Db\ExceptionInput("tooShort", ['field' => "searchTerms", 'action' => $this->caller(), 'min' => 1]); // must have at least one array element - } elseif (sizeof($context->searchTerms) > self::LIMIT_TERMS) { - throw new Db\ExceptionInput("tooLong", ['field' => "searchTerms", 'action' => $this->caller(), 'max' => self::LIMIT_TERMS]); - } - $q->setWhere(...$this->generateSearch($context->searchTerms, ["arsse_articles.title", "arsse_articles.content"])); - } - // filter based on search terms in note - if ($context->annotationTerms()) { - if (!$context->annotationTerms) { - throw new Db\ExceptionInput("tooShort", ['field' => "annotationTerms", 'action' => $this->caller(), 'min' => 1]); // must have at least one array element - } elseif (sizeof($context->annotationTerms) > self::LIMIT_TERMS) { - throw new Db\ExceptionInput("tooLong", ['field' => "annotationTerms", 'action' => $this->caller(), 'max' => self::LIMIT_TERMS]); + if ($context->folder()) { + // add a common table expression to list the folder and its children so that we select from the entire subtree + $q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on parent = folder", "int", $context->folder); + // limit subscriptions to the listed folders + $q->setWhere("arsse_subscriptions.folder in (select folder from folders)"); + } + // handle text-matching context options + foreach ([ + "titleTerms" => [10, ["arsse_articles.title"]], + "searchTerms" => [20, ["arsse_articles.title", "arsse_articles.content"]], + "authorTerms" => [10, ["arsse_articles.author"]], + "annotationTerms" => [20, ["arsse_marks.note"]], + ] as $m => list($max, $cols)) { + if (!$context->$m()) { + continue; + } elseif (!$context->$m) { + throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element + } elseif (sizeof($context->$m) > $max) { + throw new Db\ExceptionInput("tooLong", ['field' => $m, 'action' => $this->caller(), 'max' => $max]); } - $q->setWhere(...$this->generateSearch($context->annotationTerms, ["arsse_marks.note"])); + $q->setWhere(...$this->generateSearch($context->$m, $cols)); } // return the query return $q; @@ -1306,7 +1285,7 @@ class Database { * * @param string $user The user whose articles are to be listed * @param Context $context The search context - * @param array $cols The columns to return in the result set, any of: id, edition, url, title, author, content, guid, fingerprint, subscription, feed, starred, unread, note, published_date, edited_date, modified_date, marked_date, subscription_title, media_url, media_type + * @param array $cols The columns to return in the result set, any of: id, edition, url, title, author, content, guid, fingerprint, folder, subscription, feed, starred, unread, note, published_date, edited_date, modified_date, marked_date, subscription_title, media_url, media_type */ public function articleList(string $user, Context $context = null, array $fields = ["id"]): Db\Result { if (!Arsse::$user->authorize($user, __FUNCTION__)) { diff --git a/tests/cases/Database/Base.php b/tests/cases/Database/Base.php index b40056e..219d4c0 100644 --- a/tests/cases/Database/Base.php +++ b/tests/cases/Database/Base.php @@ -66,7 +66,7 @@ abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest { public function setUp() { // get the name of the test's test series - $this->series = $this->findTraitofTest($this->getName()); + $this->series = $this->findTraitofTest($this->getName(false)); static::clearData(); static::setConf(); if (strlen(static::$failureReason)) { @@ -88,7 +88,7 @@ abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest { public function tearDown() { // call the series-specific teardown method - $this->series = $this->findTraitofTest($this->getName()); + $this->series = $this->findTraitofTest($this->getName(false)); $tearDown = "tearDown".$this->series; $this->$tearDown(); // clean up diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 80114c4..8530099 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -114,10 +114,10 @@ trait SeriesArticle { [1,1,null,"Title one", null,null,null,"First article", null,"","","","2000-01-01T00:00:00Z"], [2,1,null,"Title two", null,null,null,"Second article",null,"","","","2010-01-01T00:00:00Z"], [3,2,null,"Title three",null,null,null,"Third article", null,"","","","2000-01-01T00:00:00Z"], - [4,2,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], - [5,3,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], - [6,3,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], - [7,4,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [4,2,null,null,"John Doe",null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [5,3,null,null,"John Doe",null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [6,3,null,null,"Jane Doe",null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [7,4,null,null,"Jane Doe",null,null,null,null,"","","","2000-01-01T00:00:00Z"], [8,4,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], [9,5,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], [10,5,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], @@ -414,94 +414,76 @@ trait SeriesArticle { $this->assertEquals($exp, Arsse::$db->editionArticle(...range(1, 1001))); } - public function testListArticlesCheckingContext() { - $compareIds = function(array $exp, Context $c) { - $ids = array_column($ids = Arsse::$db->articleList("john.doe@example.com", $c)->getAll(), "id"); - sort($ids); - sort($exp); - $this->assertEquals($exp, $ids); - }; - // get all items for user - $exp = [1,2,3,4,5,6,7,8,19,20]; - $compareIds($exp, new Context); - $compareIds($exp, (new Context)->articles(range(1, Database::LIMIT_ARTICLES * 3))); - // get items from a folder tree - $compareIds([5,6,7,8], (new Context)->folder(1)); - // get items from a leaf folder - $compareIds([7,8], (new Context)->folder(6)); - // get items from a non-leaf folder without descending - $compareIds([1,2,3,4], (new Context)->folderShallow(0)); - $compareIds([5,6], (new Context)->folderShallow(1)); - // get items from a single subscription - $exp = [19,20]; - $compareIds($exp, (new Context)->subscription(5)); - // get un/read items from a single subscription - $compareIds([20], (new Context)->subscription(5)->unread(true)); - $compareIds([19], (new Context)->subscription(5)->unread(false)); - // get starred articles - $compareIds([1,20], (new Context)->starred(true)); - $compareIds([2,3,4,5,6,7,8,19], (new Context)->starred(false)); - $compareIds([1], (new Context)->starred(true)->unread(false)); - $compareIds([], (new Context)->starred(true)->unread(false)->subscription(5)); - // get items relative to edition - $compareIds([19], (new Context)->subscription(5)->latestEdition(999)); - $compareIds([19], (new Context)->subscription(5)->latestEdition(19)); - $compareIds([20], (new Context)->subscription(5)->oldestEdition(999)); - $compareIds([20], (new Context)->subscription(5)->oldestEdition(1001)); - // get items relative to article ID - $compareIds([1,2,3], (new Context)->latestArticle(3)); - $compareIds([19,20], (new Context)->oldestArticle(19)); - // get items relative to (feed) modification date - $exp = [2,4,6,8,20]; - $compareIds($exp, (new Context)->modifiedSince("2005-01-01T00:00:00Z")); - $compareIds($exp, (new Context)->modifiedSince("2010-01-01T00:00:00Z")); - $exp = [1,3,5,7,19]; - $compareIds($exp, (new Context)->notModifiedSince("2005-01-01T00:00:00Z")); - $compareIds($exp, (new Context)->notModifiedSince("2000-01-01T00:00:00Z")); - // get items relative to (user) modification date (both marks and labels apply) - $compareIds([8,19], (new Context)->markedSince("2014-01-01T00:00:00Z")); - $compareIds([2,4,6,8,19,20], (new Context)->markedSince("2010-01-01T00:00:00Z")); - $compareIds([1,2,3,4,5,6,7,20], (new Context)->notMarkedSince("2014-01-01T00:00:00Z")); - $compareIds([1,3,5,7], (new Context)->notMarkedSince("2005-01-01T00:00:00Z")); - // paged results - $compareIds([1], (new Context)->limit(1)); - $compareIds([2], (new Context)->limit(1)->oldestEdition(1+1)); - $compareIds([3], (new Context)->limit(1)->oldestEdition(2+1)); - $compareIds([4,5], (new Context)->limit(2)->oldestEdition(3+1)); - // reversed results - $compareIds([20], (new Context)->reverse(true)->limit(1)); - $compareIds([19], (new Context)->reverse(true)->limit(1)->latestEdition(1001-1)); - $compareIds([8], (new Context)->reverse(true)->limit(1)->latestEdition(19-1)); - $compareIds([7,6], (new Context)->reverse(true)->limit(2)->latestEdition(8-1)); - // get articles by label ID - $compareIds([1,19], (new Context)->label(1)); - $compareIds([1,5,20], (new Context)->label(2)); - // get articles by label name - $compareIds([1,19], (new Context)->labelName("Interesting")); - $compareIds([1,5,20], (new Context)->labelName("Fascinating")); - // get articles with any or no label - $compareIds([1,5,8,19,20], (new Context)->labelled(true)); - $compareIds([2,3,4,6,7], (new Context)->labelled(false)); - // get a specific article or edition - $compareIds([20], (new Context)->article(20)); - $compareIds([20], (new Context)->edition(1001)); - // get multiple specific articles or editions - $compareIds([1,20], (new Context)->articles([1,20,50])); - $compareIds([1,20], (new Context)->editions([1,1001,50])); - // get articles base on whether or not they have notes - $compareIds([1,3,4,5,6,7,8,19,20], (new Context)->annotated(false)); - $compareIds([2], (new Context)->annotated(true)); - // get specific starred articles - $compareIds([1], (new Context)->articles([1,2,3])->starred(true)); - $compareIds([2,3], (new Context)->articles([1,2,3])->starred(false)); - // get items that match search terms - $compareIds([1,2,3], (new Context)->searchTerms(["Article"])); - $compareIds([1], (new Context)->searchTerms(["one", "first"])); - // get items that match search terms in note - $compareIds([2], (new Context)->annotationTerms(["some"])); - $compareIds([2], (new Context)->annotationTerms(["some", "note"])); - $compareIds([2], (new Context)->annotationTerms(["some note"])); - $compareIds([], (new Context)->annotationTerms(["some", "sauce"])); + /** @dataProvider provideContextMatches */ + public function testListArticlesCheckingContext(Context $c, array $exp) { + $ids = array_column($ids = Arsse::$db->articleList("john.doe@example.com", $c)->getAll(), "id"); + sort($ids); + sort($exp); + $this->assertEquals($exp, $ids); + } + + public function provideContextMatches() { + return [ + "Blank context" => [new Context, [1,2,3,4,5,6,7,8,19,20]], + "Folder tree" => [(new Context)->folder(1), [5,6,7,8]], + "Leaf folder" => [(new Context)->folder(6), [7,8]], + "Root folder only" => [(new Context)->folderShallow(0), [1,2,3,4]], + "Shallow folder" => [(new Context)->folderShallow(1), [5,6]], + "Subscription" => [(new Context)->subscription(5), [19,20]], + "Unread" => [(new Context)->subscription(5)->unread(true), [20]], + "Read" => [(new Context)->subscription(5)->unread(false), [19]], + "Starred" => [(new Context)->starred(true), [1,20]], + "Unstarred" => [(new Context)->starred(false), [2,3,4,5,6,7,8,19]], + "Starred and Read" => [(new Context)->starred(true)->unread(false), [1]], + "Starred and Read in subscription" => [(new Context)->starred(true)->unread(false)->subscription(5), []], + "Annotated" => [(new Context)->annotated(true), [2]], + "Not annotated" => [(new Context)->annotated(false), [1,3,4,5,6,7,8,19,20]], + "Labelled" => [(new Context)->labelled(true), [1,5,8,19,20]], + "Not labelled" => [(new Context)->labelled(false), [2,3,4,6,7]], + "Not after edition 999" => [(new Context)->subscription(5)->latestEdition(999), [19]], + "Not after edition 19" => [(new Context)->subscription(5)->latestEdition(19), [19]], + "Not before edition 999" => [(new Context)->subscription(5)->oldestEdition(999), [20]], + "Not before edition 1001" => [(new Context)->subscription(5)->oldestEdition(1001), [20]], + "Not after article 3" => [(new Context)->latestArticle(3), [1,2,3]], + "Not before article 19" => [(new Context)->oldestArticle(19), [19,20]], + "Modified by author since 2005" => [(new Context)->modifiedSince("2005-01-01T00:00:00Z"), [2,4,6,8,20]], + "Modified by author since 2010" => [(new Context)->modifiedSince("2010-01-01T00:00:00Z"), [2,4,6,8,20]], + "Not modified by author since 2005" => [(new Context)->notModifiedSince("2005-01-01T00:00:00Z"), [1,3,5,7,19]], + "Not modified by author since 2000" => [(new Context)->notModifiedSince("2000-01-01T00:00:00Z"), [1,3,5,7,19]], + "Marked or labelled since 2014" => [(new Context)->markedSince("2014-01-01T00:00:00Z"), [8,19]], + "Marked or labelled since 2010" => [(new Context)->markedSince("2010-01-01T00:00:00Z"), [2,4,6,8,19,20]], + "Not marked or labelled since 2014" => [(new Context)->notMarkedSince("2014-01-01T00:00:00Z"), [1,2,3,4,5,6,7,20]], + "Not marked or labelled since 2005" => [(new Context)->notMarkedSince("2005-01-01T00:00:00Z"), [1,3,5,7]], + "Paged results" => [(new Context)->limit(2)->oldestEdition(4), [4,5]], + "Reversed paged results" => [(new Context)->limit(2)->latestEdition(7)->reverse(true), [7,6]], + "With label ID 1" => [(new Context)->label(1), [1,19]], + "With label ID 2" => [(new Context)->label(2), [1,5,20]], + "With label 'Interesting'" => [(new Context)->labelName("Interesting"), [1,19]], + "With label 'Fascinating'" => [(new Context)->labelName("Fascinating"), [1,5,20]], + "Article ID 20" => [(new Context)->article(20), [20]], + "Edition ID 1001" => [(new Context)->edition(1001), [20]], + "Multiple articles" => [(new Context)->articles([1,20,50]), [1,20]], + "Multiple starred articles" => [(new Context)->articles([1,2,3])->starred(true), [1]], + "Multiple unstarred articles" => [(new Context)->articles([1,2,3])->starred(false), [2,3]], + "Multiple articles" => [(new Context)->articles([1,20,50]), [1,20]], + "Multiple editions" => [(new Context)->editions([1,1001,50]), [1,20]], + "150 articles" => [(new Context)->articles(range(1, Database::LIMIT_ARTICLES * 3)), [1,2,3,4,5,6,7,8,19,20]], + "Search title or content 1" => [(new Context)->searchTerms(["Article"]), [1,2,3]], + "Search title or content 2" => [(new Context)->searchTerms(["one", "first"]), [1]], + "Search title or content 3" => [(new Context)->searchTerms(["one first"]), []], + "Search title 1" => [(new Context)->titleTerms(["two"]), [2]], + "Search title 2" => [(new Context)->titleTerms(["title two"]), [2]], + "Search title 3" => [(new Context)->titleTerms(["two", "title"]), [2]], + "Search title 4" => [(new Context)->titleTerms(["two title"]), []], + "Search note 1" => [(new Context)->annotationTerms(["some"]), [2]], + "Search note 2" => [(new Context)->annotationTerms(["some Note"]), [2]], + "Search note 3" => [(new Context)->annotationTerms(["note", "some"]), [2]], + "Search note 4" => [(new Context)->annotationTerms(["some", "sauce"]), []], + "Search author 1" => [(new Context)->authorTerms(["doe"]), [4,5,6,7]], + "Search author 2" => [(new Context)->authorTerms(["jane doe"]), [6,7]], + "Search author 3" => [(new Context)->authorTerms(["doe", "jane"]), [6,7]], + "Search author 4" => [(new Context)->authorTerms(["doe jane"]), []], + ]; } public function testListArticlesOfAMissingFolder() { From b950ac066f153fc4db108c0da62a872183000d9d Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Feb 2019 22:41:12 -0500 Subject: [PATCH 017/142] Restrict options in not-context and hopefully make it easier to use --- lib/Context/Context.php | 101 +++++++++++++++++ .../ExclusionContext.php} | 102 ++---------------- lib/Database.php | 28 ++++- lib/Misc/Query.php | 26 ++++- lib/REST/NextCloudNews/V1_2.php | 2 +- lib/REST/TinyTinyRSS/API.php | 2 +- tests/cases/Database/SeriesArticle.php | 2 +- tests/cases/Database/SeriesLabel.php | 2 +- tests/cases/Misc/TestContext.php | 13 ++- tests/cases/REST/NextCloudNews/TestV1_2.php | 2 +- tests/cases/REST/TinyTinyRSS/TestAPI.php | 2 +- 11 files changed, 170 insertions(+), 112 deletions(-) create mode 100644 lib/Context/Context.php rename lib/{Misc/Context.php => Context/ExclusionContext.php} (56%) diff --git a/lib/Context/Context.php b/lib/Context/Context.php new file mode 100644 index 0000000..d997773 --- /dev/null +++ b/lib/Context/Context.php @@ -0,0 +1,101 @@ +not = new ExclusionContext; + } + + public function __clone() { + // clone the exclusion context as well + $this->not = clone $this->not; + } + + public function reverse(bool $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function limit(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function offset(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function unread(bool $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function starred(bool $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function labelled(bool $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function annotated(bool $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function latestArticle(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function oldestArticle(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function latestEdition(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function oldestEdition(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function modifiedSince($spec = null) { + $spec = Date::normalize($spec); + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function notModifiedSince($spec = null) { + $spec = Date::normalize($spec); + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function markedSince($spec = null) { + $spec = Date::normalize($spec); + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function notMarkedSince($spec = null) { + $spec = Date::normalize($spec); + return $this->act(__FUNCTION__, func_num_args(), $spec); + } +} diff --git a/lib/Misc/Context.php b/lib/Context/ExclusionContext.php similarity index 56% rename from lib/Misc/Context.php rename to lib/Context/ExclusionContext.php index 9263fa1..5a2a9cf 100644 --- a/lib/Misc/Context.php +++ b/lib/Context/ExclusionContext.php @@ -4,49 +4,27 @@ * See LICENSE and AUTHORS files for details */ declare(strict_types=1); -namespace JKingWeb\Arsse\Misc; +namespace JKingWeb\Arsse\Context; -use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\ValueInfo; -class Context { - public $not = null; - public $reverse = false; - public $limit = 0; - public $offset = 0; +class ExclusionContext { public $folder; public $folderShallow; public $subscription; - public $oldestArticle; - public $latestArticle; - public $oldestEdition; - public $latestEdition; - public $unread = null; - public $starred = null; - public $modifiedSince; - public $notModifiedSince; - public $markedSince; - public $notMarkedSince; public $edition; public $article; public $editions; public $articles; public $label; public $labelName; - public $labelled = null; - public $annotated = null; - public $annotationTerms = null; - public $searchTerms = null; - public $titleTerms = null; - public $authorTerms = null; + public $annotationTerms; + public $searchTerms; + public $titleTerms; + public $authorTerms; protected $props = []; - public function __clone() { - // clone the negation context, if any - $this->not = $this->not ? clone $this->not : null; - } - protected function act(string $prop, int $set, $value) { if ($set) { if (is_null($value)) { @@ -87,18 +65,6 @@ class Context { return array_values($spec); } - public function reverse(bool $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function limit(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function offset(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - public function folder(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } @@ -111,50 +77,6 @@ class Context { return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function latestArticle(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function oldestArticle(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function latestEdition(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function oldestEdition(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function unread(bool $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function starred(bool $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function modifiedSince($spec = null) { - $spec = Date::normalize($spec); - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function notModifiedSince($spec = null) { - $spec = Date::normalize($spec); - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function markedSince($spec = null) { - $spec = Date::normalize($spec); - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function notMarkedSince($spec = null) { - $spec = Date::normalize($spec); - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - public function edition(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } @@ -185,14 +107,6 @@ class Context { return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function labelled(bool $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function annotated(bool $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - public function annotationTerms(array $spec = null) { if (isset($spec)) { $spec = $this->cleanStringArray($spec); @@ -220,8 +134,4 @@ class Context { } return $this->act(__FUNCTION__, func_num_args(), $spec); } - - public function not(self $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } } diff --git a/lib/Database.php b/lib/Database.php index 353e7b9..6f7a9b4 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -9,7 +9,8 @@ namespace JKingWeb\Arsse; use JKingWeb\DrUUID\UUID; use JKingWeb\Arsse\Db\Statement; use JKingWeb\Arsse\Misc\Query; -use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Context\Context; +use JKingWeb\Arsse\Context\ExclusionContext; use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\ValueInfo; @@ -1178,8 +1179,9 @@ class Database { // if there are no output columns requested we're getting a count and should not group, but otherwise we should $q->setGroup("arsse_articles.id", "arsse_marks.note", "arsse_enclosures.url", "arsse_enclosures.type", "arsse_subscriptions.title", "arsse_feeds.title", "arsse_subscriptions.id", "arsse_marks.modified", "arsse_label_members.modified", "arsse_marks.read", "arsse_marks.starred", "latest_editions.edition"); } + $excContext = new ExclusionContext; // handle the simple context options - foreach ([ + $options = [ // each context array consists of a column identifier (see $colDefs above), a comparison operator, a data type, and an upper bound if the value is an array "edition" => ["edition", "=", "int", 1], "editions" => ["edition", "in", "int", self::LIMIT_ARTICLES], @@ -1197,7 +1199,8 @@ class Database { "subscription" => ["subscription", "=", "int", 1], "unread" => ["unread", "=", "bool", 1], "starred" => ["starred", "=", "bool", 1], - ] as $m => list($col, $op, $type, $max)) { + ]; + foreach ($options as $m => list($col, $op, $type, $max)) { if (!$context->$m()) { // context is not being used continue; @@ -1213,6 +1216,25 @@ class Database { $q->setWhere("{$colDefs[$col]} $op ?", $type, $context->$m); } } + if ($context->not != $excContext) { + // further handle exclusionary options if specified + foreach ($options as $m => list($col, $op, $type, $max)) { + if (!method_exists($context->not, $m) || !$context->not->$m()) { + // context option is not being used + continue; + } elseif (is_array($context->not->$m)) { + if (!$context->not->$m) { + // for exclusions we don't care if the array is empty + } elseif (sizeof($context->not->$m) > $max) { + throw new Db\ExceptionInput("tooLong", ['field' => $m, 'action' => $this->caller(), 'max' => $max]); // @codeCoverageIgnore + } + list($clause, $types, $values) = $this->generateIn($context->$m, $type); + $q->setWhereNot("{$colDefs[$col]} $op ($clause)", $types, $values); + } else { + $q->setWhereNot("{$colDefs[$col]} $op ?", $type, $context->$m); + } + } + } // handle complex context options if ($context->labelled()) { // any label (true) or no label (false) diff --git a/lib/Misc/Query.php b/lib/Misc/Query.php index d7a2c7f..458b7ed 100644 --- a/lib/Misc/Query.php +++ b/lib/Misc/Query.php @@ -20,6 +20,9 @@ class Query { protected $qWhere = []; // WHERE clause components protected $tWhere = []; // WHERE clause type bindings protected $vWhere = []; // WHERE clause binding values + protected $qWhereNot = []; // WHERE NOT clause components + protected $tWhereNot = []; // WHERE NOT clause type bindings + protected $vWhereNot = []; // WHERE NOT clause binding values protected $group = []; // GROUP BY clause components protected $order = []; // ORDER BY clause components protected $limit = 0; @@ -69,6 +72,15 @@ class Query { return true; } + public function setWhereNot(string $where, $types = null, $values = null): bool { + $this->qWhereNot[] = $where; + if (!is_null($types)) { + $this->tWhereNot[] = $types; + $this->vWhereNot[] = $values; + } + return true; + } + public function setGroup(string ...$column): bool { foreach ($column as $col) { $this->group[] = $col; @@ -94,7 +106,7 @@ class Query { public function pushCTE(string $tableSpec, string $join = ''): bool { // this function takes the query body and converts it to a common table expression, putting it at the bottom of the existing CTE stack // all WHERE, ORDER BY, and LIMIT parts belong to the new CTE and are removed from the main query - $this->setCTE($tableSpec, $this->buildQueryBody(), [$this->tBody, $this->tWhere], [$this->vBody, $this->vWhere]); + $this->setCTE($tableSpec, $this->buildQueryBody(), [$this->tBody, $this->tWhere, $this->tWhereNot], [$this->vBody, $this->vWhere, $this->vWhereNot]); $this->jCTE = []; $this->tBody = []; $this->vBody = []; @@ -129,11 +141,11 @@ class Query { } public function getTypes(): array { - return [$this->tCTE, $this->tBody, $this->tJoin, $this->tWhere]; + return [$this->tCTE, $this->tBody, $this->tJoin, $this->tWhere, $this->tWhereNot]; } public function getValues(): array { - return [$this->vCTE, $this->vBody, $this->vJoin, $this->vWhere]; + return [$this->vCTE, $this->vBody, $this->vJoin, $this->vWhere, $this->vWhereNot]; } public function getJoinTypes(): array { @@ -173,8 +185,12 @@ class Query { $out .= " ".implode(" ", $this->qJoin); } // add any WHERE terms - if (sizeof($this->qWhere)) { - $out .= " WHERE ".implode(" AND ", $this->qWhere); + if (sizeof($this->qWhere) || sizeof($this->qWhereNot)) { + $where = implode(" AND ", $this->qWhere); + $whereNot = implode(" OR ", $this->qWhereNot); + $whereNot = strlen($whereNot) ? "NOT ($whereNot)" : ""; + $where = implode(" AND ", array_filter([$where, $whereNot])); + $out .= " WHERE $where"; } // add any GROUP BY terms if (sizeof($this->group)) { diff --git a/lib/REST/NextCloudNews/V1_2.php b/lib/REST/NextCloudNews/V1_2.php index 84beda3..7f4301c 100644 --- a/lib/REST/NextCloudNews/V1_2.php +++ b/lib/REST/NextCloudNews/V1_2.php @@ -10,7 +10,7 @@ use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Database; use JKingWeb\Arsse\User; use JKingWeb\Arsse\Service; -use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Misc\ValueInfo; use JKingWeb\Arsse\AbstractException; use JKingWeb\Arsse\Db\ExceptionInput; diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 4ddea6f..f126324 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -12,7 +12,7 @@ use JKingWeb\Arsse\Database; use JKingWeb\Arsse\User; use JKingWeb\Arsse\Service; use JKingWeb\Arsse\Misc\Date; -use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Misc\ValueInfo; use JKingWeb\Arsse\AbstractException; use JKingWeb\Arsse\ExceptionType; diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 8530099..37a5311 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -8,7 +8,7 @@ namespace JKingWeb\Arsse\TestCase\Database; use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Arsse; -use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Misc\Date; use Phake; diff --git a/tests/cases/Database/SeriesLabel.php b/tests/cases/Database/SeriesLabel.php index 8347ce5..e6fc426 100644 --- a/tests/cases/Database/SeriesLabel.php +++ b/tests/cases/Database/SeriesLabel.php @@ -7,7 +7,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\Database; use JKingWeb\Arsse\Arsse; -use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Misc\Date; use Phake; diff --git a/tests/cases/Misc/TestContext.php b/tests/cases/Misc/TestContext.php index 12a9969..db088b4 100644 --- a/tests/cases/Misc/TestContext.php +++ b/tests/cases/Misc/TestContext.php @@ -6,10 +6,10 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\Misc; -use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Misc\ValueInfo; -/** @covers \JKingWeb\Arsse\Misc\Context */ +/** @covers \JKingWeb\Arsse\Context\Context */ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { public function testVerifyInitialState() { $c = new Context; @@ -96,4 +96,13 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { $this->assertSame($out, $c->$method($in)->$method, "Context method $method did not return the expected results"); } } + + public function testCloneAContext() { + $c1 = new Context; + $c2 = clone $c1; + $this->assertEquals($c1, $c2); + $this->assertEquals($c1->not, $c2->not); + $this->assertNotSame($c1, $c2); + $this->assertNotSame($c1->not, $c2->not); + } } diff --git a/tests/cases/REST/NextCloudNews/TestV1_2.php b/tests/cases/REST/NextCloudNews/TestV1_2.php index f35e21e..664db4e 100644 --- a/tests/cases/REST/NextCloudNews/TestV1_2.php +++ b/tests/cases/REST/NextCloudNews/TestV1_2.php @@ -13,7 +13,7 @@ use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Service; use JKingWeb\Arsse\Test\Result; use JKingWeb\Arsse\Misc\Date; -use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\Transaction; use JKingWeb\Arsse\REST\NextCloudNews\V1_2; diff --git a/tests/cases/REST/TinyTinyRSS/TestAPI.php b/tests/cases/REST/TinyTinyRSS/TestAPI.php index bf35a30..4b497a7 100644 --- a/tests/cases/REST/TinyTinyRSS/TestAPI.php +++ b/tests/cases/REST/TinyTinyRSS/TestAPI.php @@ -14,7 +14,7 @@ use JKingWeb\Arsse\Service; use JKingWeb\Arsse\REST\Request; use JKingWeb\Arsse\Test\Result; use JKingWeb\Arsse\Misc\Date; -use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\Transaction; use JKingWeb\Arsse\REST\TinyTinyRSS\API; From 18d52ea402785a6ca3eaa3f629a5f7bb1b7695f3 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Feb 2019 23:37:14 -0500 Subject: [PATCH 018/142] Make exclusion contexts return their parent on change --- lib/Context/Context.php | 6 +++++- lib/Context/ExclusionContext.php | 20 +++++++++++++++++++- tests/cases/Misc/TestContext.php | 2 ++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/lib/Context/Context.php b/lib/Context/Context.php index d997773..df45dc6 100644 --- a/lib/Context/Context.php +++ b/lib/Context/Context.php @@ -27,7 +27,7 @@ class Context extends ExclusionContext { public $notMarkedSince; public function __construct() { - $this->not = new ExclusionContext; + $this->not = new ExclusionContext($this); } public function __clone() { @@ -35,6 +35,10 @@ class Context extends ExclusionContext { $this->not = clone $this->not; } + public function __destruct() { + unset($this->not); + } + public function reverse(bool $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } diff --git a/lib/Context/ExclusionContext.php b/lib/Context/ExclusionContext.php index 5a2a9cf..6a662f2 100644 --- a/lib/Context/ExclusionContext.php +++ b/lib/Context/ExclusionContext.php @@ -24,6 +24,24 @@ class ExclusionContext { public $authorTerms; protected $props = []; + protected $parent; + + public function __construct(self $c = null) { + $this->parent = $c; + } + + public function __clone() { + if ($this->parent) { + $p = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT, 2)[1]['object'] ?? null; + if ($p instanceof self) { + $this->parent = $p; + } + } + } + + public function __destruct() { + unset($this->parent); + } protected function act(string $prop, int $set, $value) { if ($set) { @@ -34,7 +52,7 @@ class ExclusionContext { $this->props[$prop] = true; $this->$prop = $value; } - return $this; + return $this->parent ?? $this; } else { return isset($this->props[$prop]); } diff --git a/tests/cases/Misc/TestContext.php b/tests/cases/Misc/TestContext.php index db088b4..d134c0f 100644 --- a/tests/cases/Misc/TestContext.php +++ b/tests/cases/Misc/TestContext.php @@ -104,5 +104,7 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { $this->assertEquals($c1->not, $c2->not); $this->assertNotSame($c1, $c2); $this->assertNotSame($c1->not, $c2->not); + $this->assertSame($c1, $c1->not->article(null)); + $this->assertSame($c2, $c2->not->article(null)); } } From 70443a52640450caed1e6dff81da35a57f3daf9c Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Feb 2019 23:59:48 -0500 Subject: [PATCH 019/142] Make parent re-association on context clone more restrictive --- lib/Context/Context.php | 1 + lib/Context/ExclusionContext.php | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/Context/Context.php b/lib/Context/Context.php index df45dc6..4ae2e87 100644 --- a/lib/Context/Context.php +++ b/lib/Context/Context.php @@ -9,6 +9,7 @@ namespace JKingWeb\Arsse\Context; use JKingWeb\Arsse\Misc\Date; class Context extends ExclusionContext { + /** @var ExclusionContext */ public $not; public $reverse = false; public $limit = 0; diff --git a/lib/Context/ExclusionContext.php b/lib/Context/ExclusionContext.php index 6a662f2..b2954e3 100644 --- a/lib/Context/ExclusionContext.php +++ b/lib/Context/ExclusionContext.php @@ -32,9 +32,9 @@ class ExclusionContext { public function __clone() { if ($this->parent) { - $p = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT, 2)[1]['object'] ?? null; - if ($p instanceof self) { - $this->parent = $p; + $t = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT, 2)[1]; + if (($t['object'] ?? null) instanceof self && $t['function'] === "__clone") { + $this->parent = $t['object']; } } } From 0dc82f64d5ae2764a2e8f4b95708790b27680345 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 26 Feb 2019 11:11:42 -0500 Subject: [PATCH 020/142] Allow ranges in exclusion contexts --- lib/Context/Context.php | 46 -------------- lib/Context/ExclusionContext.php | 45 ++++++++++++++ lib/Database.php | 83 ++++++++++++++++---------- lib/Misc/Query.php | 3 + tests/cases/Database/SeriesArticle.php | 80 +++++++++++++------------ 5 files changed, 141 insertions(+), 116 deletions(-) diff --git a/lib/Context/Context.php b/lib/Context/Context.php index 4ae2e87..922c535 100644 --- a/lib/Context/Context.php +++ b/lib/Context/Context.php @@ -6,8 +6,6 @@ declare(strict_types=1); namespace JKingWeb\Arsse\Context; -use JKingWeb\Arsse\Misc\Date; - class Context extends ExclusionContext { /** @var ExclusionContext */ public $not; @@ -18,14 +16,6 @@ class Context extends ExclusionContext { public $starred; public $labelled; public $annotated; - public $oldestArticle; - public $latestArticle; - public $oldestEdition; - public $latestEdition; - public $modifiedSince; - public $notModifiedSince; - public $markedSince; - public $notMarkedSince; public function __construct() { $this->not = new ExclusionContext($this); @@ -67,40 +57,4 @@ class Context extends ExclusionContext { public function annotated(bool $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } - - public function latestArticle(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function oldestArticle(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function latestEdition(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function oldestEdition(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function modifiedSince($spec = null) { - $spec = Date::normalize($spec); - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function notModifiedSince($spec = null) { - $spec = Date::normalize($spec); - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function markedSince($spec = null) { - $spec = Date::normalize($spec); - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function notMarkedSince($spec = null) { - $spec = Date::normalize($spec); - return $this->act(__FUNCTION__, func_num_args(), $spec); - } } diff --git a/lib/Context/ExclusionContext.php b/lib/Context/ExclusionContext.php index b2954e3..cfec246 100644 --- a/lib/Context/ExclusionContext.php +++ b/lib/Context/ExclusionContext.php @@ -7,6 +7,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse\Context; use JKingWeb\Arsse\Misc\ValueInfo; +use JKingWeb\Arsse\Misc\Date; class ExclusionContext { public $folder; @@ -22,6 +23,14 @@ class ExclusionContext { public $searchTerms; public $titleTerms; public $authorTerms; + public $oldestArticle; + public $latestArticle; + public $oldestEdition; + public $latestEdition; + public $modifiedSince; + public $notModifiedSince; + public $markedSince; + public $notMarkedSince; protected $props = []; protected $parent; @@ -152,4 +161,40 @@ class ExclusionContext { } return $this->act(__FUNCTION__, func_num_args(), $spec); } + + public function latestArticle(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function oldestArticle(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function latestEdition(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function oldestEdition(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function modifiedSince($spec = null) { + $spec = Date::normalize($spec); + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function notModifiedSince($spec = null) { + $spec = Date::normalize($spec); + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function markedSince($spec = null) { + $spec = Date::normalize($spec); + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function notMarkedSince($spec = null) { + $spec = Date::normalize($spec); + return $this->act(__FUNCTION__, func_num_args(), $spec); + } } diff --git a/lib/Database.php b/lib/Database.php index 6f7a9b4..13f49bf 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1179,32 +1179,34 @@ class Database { // if there are no output columns requested we're getting a count and should not group, but otherwise we should $q->setGroup("arsse_articles.id", "arsse_marks.note", "arsse_enclosures.url", "arsse_enclosures.type", "arsse_subscriptions.title", "arsse_feeds.title", "arsse_subscriptions.id", "arsse_marks.modified", "arsse_label_members.modified", "arsse_marks.read", "arsse_marks.starred", "latest_editions.edition"); } - $excContext = new ExclusionContext; // handle the simple context options $options = [ - // each context array consists of a column identifier (see $colDefs above), a comparison operator, a data type, and an upper bound if the value is an array - "edition" => ["edition", "=", "int", 1], - "editions" => ["edition", "in", "int", self::LIMIT_ARTICLES], - "article" => ["id", "=", "int", 1], - "articles" => ["id", "in", "int", self::LIMIT_ARTICLES], - "oldestArticle" => ["id", ">=", "int", 1], - "latestArticle" => ["id", "<=", "int", 1], - "oldestEdition" => ["edition", ">=", "int", 1], - "latestEdition" => ["edition", "<=", "int", 1], - "modifiedSince" => ["modified_date", ">=", "datetime", 1], - "notModifiedSince" => ["modified_date", "<=", "datetime", 1], - "markedSince" => ["marked_date", ">=", "datetime", 1], - "notMarkedSince" => ["marked_date", "<=", "datetime", 1], - "folderShallow" => ["folder", "=", "int", 1], - "subscription" => ["subscription", "=", "int", 1], - "unread" => ["unread", "=", "bool", 1], - "starred" => ["starred", "=", "bool", 1], + // each context array consists of a column identifier (see $colDefs above), a comparison operator, a data type, an option to pair with for BETWEEN evaluation, and an upper bound if the value is an array + "edition" => ["edition", "=", "int", "", 1], + "editions" => ["edition", "in", "int", "", self::LIMIT_ARTICLES], + "article" => ["id", "=", "int", "", 1], + "articles" => ["id", "in", "int", "", self::LIMIT_ARTICLES], + "oldestArticle" => ["id", ">=", "int", "latestArticle", 1], + "latestArticle" => ["id", "<=", "int", "oldestArticle", 1], + "oldestEdition" => ["edition", ">=", "int", "latestEdition", 1], + "latestEdition" => ["edition", "<=", "int", "oldestEdition", 1], + "modifiedSince" => ["modified_date", ">=", "datetime", "notModifiedSince", 1], + "notModifiedSince" => ["modified_date", "<=", "datetime", "modifiedSince", 1], + "markedSince" => ["marked_date", ">=", "datetime", "notMarkedSince", 1], + "notMarkedSince" => ["marked_date", "<=", "datetime", "markedSince", 1], + "folderShallow" => ["folder", "=", "int", "", 1], + "subscription" => ["subscription", "=", "int", "", 1], + "unread" => ["unread", "=", "bool", "", 1], + "starred" => ["starred", "=", "bool", "", 1], ]; - foreach ($options as $m => list($col, $op, $type, $max)) { + $optionsSeen = []; + foreach ($options as $m => list($col, $op, $type, $pair, $max)) { + if (!$context->$m()) { // context is not being used continue; } elseif (is_array($context->$m)) { + // context option is an array of values if (!$context->$m) { throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element } elseif (sizeof($context->$m) > $max) { @@ -1212,27 +1214,42 @@ class Database { } list($clause, $types, $values) = $this->generateIn($context->$m, $type); $q->setWhere("{$colDefs[$col]} $op ($clause)", $types, $values); + } elseif ($pair && $context->$pair()) { + // option is paired with another which is also being used + if ($op === ">=") { + $q->setWhere("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], [$context->$m, $context->$pair]); + } else { + // option has already been paired + continue; + } } else { $q->setWhere("{$colDefs[$col]} $op ?", $type, $context->$m); } } - if ($context->not != $excContext) { - // further handle exclusionary options if specified - foreach ($options as $m => list($col, $op, $type, $max)) { - if (!method_exists($context->not, $m) || !$context->not->$m()) { - // context option is not being used + // further handle exclusionary options if specified + foreach ($options as $m => list($col, $op, $type, $pair, $max)) { + if (!method_exists($context->not, $m) || !$context->not->$m()) { + // context option is not being used + continue; + } elseif (is_array($context->not->$m)) { + if (!$context->not->$m) { + // for exclusions we don't care if the array is empty continue; - } elseif (is_array($context->not->$m)) { - if (!$context->not->$m) { - // for exclusions we don't care if the array is empty - } elseif (sizeof($context->not->$m) > $max) { - throw new Db\ExceptionInput("tooLong", ['field' => $m, 'action' => $this->caller(), 'max' => $max]); // @codeCoverageIgnore - } - list($clause, $types, $values) = $this->generateIn($context->$m, $type); - $q->setWhereNot("{$colDefs[$col]} $op ($clause)", $types, $values); + } elseif (sizeof($context->not->$m) > $max) { + throw new Db\ExceptionInput("tooLong", ['field' => "$m (not)", 'action' => $this->caller(), 'max' => $max]); + } + list($clause, $types, $values) = $this->generateIn($context->not->$m, $type); + $q->setWhereNot("{$colDefs[$col]} $op ($clause)", $types, $values); + } elseif ($pair && $context->not->$pair()) { + // option is paired with another which is also being used + if ($op === ">=") { + $q->setWhereNot("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], [$context->not->$m, $context->not->$pair]); } else { - $q->setWhereNot("{$colDefs[$col]} $op ?", $type, $context->$m); + // option has already been paired + continue; } + } else { + $q->setWhereNot("{$colDefs[$col]} $op ?", $type, $context->not->$m); } } // handle complex context options diff --git a/lib/Misc/Query.php b/lib/Misc/Query.php index 458b7ed..5a1b0b8 100644 --- a/lib/Misc/Query.php +++ b/lib/Misc/Query.php @@ -113,6 +113,9 @@ class Query { $this->qWhere = []; $this->tWhere = []; $this->vWhere = []; + $this->qWhereNot = []; + $this->tWhereNot = []; + $this->vWhereNot = []; $this->qJoin = []; $this->tJoin = []; $this->vJoin = []; diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 37a5311..85b4c22 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -377,43 +377,6 @@ trait SeriesArticle { unset($this->data, $this->matches, $this->fields, $this->checkTables, $this->user); } - public function testRetrieveArticleIdsForEditions() { - $exp = [ - 1 => 1, - 2 => 2, - 3 => 3, - 4 => 4, - 5 => 5, - 6 => 6, - 7 => 7, - 8 => 8, - 9 => 9, - 10 => 10, - 11 => 11, - 12 => 12, - 13 => 13, - 14 => 14, - 15 => 15, - 16 => 16, - 17 => 17, - 18 => 18, - 19 => 19, - 20 => 20, - 101 => 101, - 102 => 102, - 103 => 103, - 104 => 104, - 105 => 105, - 202 => 102, - 203 => 103, - 204 => 104, - 205 => 105, - 305 => 105, - 1001 => 20, - ]; - $this->assertEquals($exp, Arsse::$db->editionArticle(...range(1, 1001))); - } - /** @dataProvider provideContextMatches */ public function testListArticlesCheckingContext(Context $c, array $exp) { $ids = array_column($ids = Arsse::$db->articleList("john.doe@example.com", $c)->getAll(), "id"); @@ -454,6 +417,8 @@ trait SeriesArticle { "Marked or labelled since 2010" => [(new Context)->markedSince("2010-01-01T00:00:00Z"), [2,4,6,8,19,20]], "Not marked or labelled since 2014" => [(new Context)->notMarkedSince("2014-01-01T00:00:00Z"), [1,2,3,4,5,6,7,20]], "Not marked or labelled since 2005" => [(new Context)->notMarkedSince("2005-01-01T00:00:00Z"), [1,3,5,7]], + "Marked or labelled between 2000 and 2015" => [(new Context)->markedSince("2000-01-01T00:00:00Z")->notMarkedSince("2015-12-31T23:59:59Z"), [1,2,3,4,5,6,7,8,20]], + "Marked or labelled in 2010" => [(new Context)->markedSince("2010-01-01T00:00:00Z")->notMarkedSince("2010-12-31T23:59:59Z"), [2,4,6,20]], "Paged results" => [(new Context)->limit(2)->oldestEdition(4), [4,5]], "Reversed paged results" => [(new Context)->limit(2)->latestEdition(7)->reverse(true), [7,6]], "With label ID 1" => [(new Context)->label(1), [1,19]], @@ -483,9 +448,50 @@ trait SeriesArticle { "Search author 2" => [(new Context)->authorTerms(["jane doe"]), [6,7]], "Search author 3" => [(new Context)->authorTerms(["doe", "jane"]), [6,7]], "Search author 4" => [(new Context)->authorTerms(["doe jane"]), []], + "Folder tree 1 excluding subscription 4" => [(new Context)->not->subscription(4)->folder(1), [5,6]], + "Folder tree 1 excluding articles 7 and 8" => [(new Context)->folder(1)->not->articles([7,8]), [5,6]], + "Folder tree 1 excluding no articles" => [(new Context)->folder(1)->not->articles([]), [5,6,7,8]], + "Marked or labelled between 2000 and 2015 excluding in 2010" => [(new Context)->markedSince("2000-01-01T00:00:00Z")->notMarkedSince("2015-12-31T23:59:59")->not->markedSince("2010-01-01T00:00:00Z")->not->notMarkedSince("2010-12-31T23:59:59Z"), [1,3,5,7,8]], ]; } + public function testRetrieveArticleIdsForEditions() { + $exp = [ + 1 => 1, + 2 => 2, + 3 => 3, + 4 => 4, + 5 => 5, + 6 => 6, + 7 => 7, + 8 => 8, + 9 => 9, + 10 => 10, + 11 => 11, + 12 => 12, + 13 => 13, + 14 => 14, + 15 => 15, + 16 => 16, + 17 => 17, + 18 => 18, + 19 => 19, + 20 => 20, + 101 => 101, + 102 => 102, + 103 => 103, + 104 => 104, + 105 => 105, + 202 => 102, + 203 => 103, + 204 => 104, + 205 => 105, + 305 => 105, + 1001 => 20, + ]; + $this->assertEquals($exp, Arsse::$db->editionArticle(...range(1, 1001))); + } + public function testListArticlesOfAMissingFolder() { $this->assertException("idMissing", "Db", "ExceptionInput"); Arsse::$db->articleList($this->user, (new Context)->folder(1)); From 89f25d7b91e8be953a727200a25a01a588dc9f56 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 26 Feb 2019 11:12:40 -0500 Subject: [PATCH 021/142] Fix coverage a little --- lib/Context/Context.php | 1 + lib/Context/ExclusionContext.php | 1 + 2 files changed, 2 insertions(+) diff --git a/lib/Context/Context.php b/lib/Context/Context.php index 922c535..858409f 100644 --- a/lib/Context/Context.php +++ b/lib/Context/Context.php @@ -26,6 +26,7 @@ class Context extends ExclusionContext { $this->not = clone $this->not; } + /** @codeCoverageIgnore */ public function __destruct() { unset($this->not); } diff --git a/lib/Context/ExclusionContext.php b/lib/Context/ExclusionContext.php index cfec246..9fc2381 100644 --- a/lib/Context/ExclusionContext.php +++ b/lib/Context/ExclusionContext.php @@ -48,6 +48,7 @@ class ExclusionContext { } } + /** @codeCoverageIgnore */ public function __destruct() { unset($this->parent); } From 677e33e5185ee4792a8e04ca77d8d222ba0d0a9d Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 26 Feb 2019 11:39:19 -0500 Subject: [PATCH 022/142] Add text search exclusions --- lib/Database.php | 33 ++++++++++++++++++-------- tests/cases/Database/SeriesArticle.php | 1 + 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 13f49bf..a806775 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -159,8 +159,9 @@ class Database { * * @param string[] $terms The terms to search for * @param string[] $cols The columns to match against; these are -not- sanitized, so much -not- come directly from user input + * @param boolean $matchAny Whether the search is successful when it matches any (true) or all (false) terms */ - protected function generateSearch(array $terms, array $cols): array { + protected function generateSearch(array $terms, array $cols, bool $matchAny = false): array { $clause = []; $types = []; $values = []; @@ -176,7 +177,8 @@ class Database { } $clause[] = "(".implode(" or ", $spec).")"; } - $clause = "(".implode(" and ", $clause).")"; + $glue = $matchAny ? "or" : "and"; + $clause = "(".implode(" $glue ", $clause).")"; return [$clause, $types, $values]; } @@ -382,7 +384,7 @@ class Database { * * @param string $uer The user whose folders are to be listed * @param integer|null $parent Restricts the list to the descendents of the specified folder identifier - * @param boolean $recursive Whether to list all descendents, or only direct children + * @param boolean $recursive Whether to list all descendents (true) or only direct children (false) */ public function folderList(string $user, $parent = null, bool $recursive = true): Db\Result { // if the user isn't authorized to perform this action then throw an exception. @@ -500,7 +502,7 @@ class Database { * * @param string $user The user who owns the folder to be validated * @param integer|null $id The identifier of the folder to validate; null or zero represent the implied root folder - * @param boolean $subject Whether the folder is the subject rather than the object of the operation being performed; this only affects the semantics of the error message if validation fails + * @param boolean $subject Whether the folder is the subject (true) rather than the object (false) of the operation being performed; this only affects the semantics of the error message if validation fails */ protected function folderValidateId(string $user, $id = null, bool $subject = false): array { // if the specified ID is not a non-negative integer (or null), this will always fail @@ -839,7 +841,7 @@ class Database { * * @param string $user The user who owns the subscription to be validated * @param integer|null $id The identifier of the subscription to validate - * @param boolean $subject Whether the subscription is the subject rather than the object of the operation being performed; this only affects the semantics of the error message if validation fails + * @param boolean $subject Whether the subscription is the subject (true) rather than the object (false) of the operation being performed; this only affects the semantics of the error message if validation fails */ protected function subscriptionValidateId(string $user, $id, bool $subject = false): array { if (!ValueInfo::id($id)) { @@ -1199,7 +1201,6 @@ class Database { "unread" => ["unread", "=", "bool", "", 1], "starred" => ["starred", "=", "bool", "", 1], ]; - $optionsSeen = []; foreach ($options as $m => list($col, $op, $type, $pair, $max)) { if (!$context->$m()) { @@ -1273,12 +1274,13 @@ class Database { $q->setWhere("arsse_subscriptions.folder in (select folder from folders)"); } // handle text-matching context options - foreach ([ + $options = [ "titleTerms" => [10, ["arsse_articles.title"]], "searchTerms" => [20, ["arsse_articles.title", "arsse_articles.content"]], "authorTerms" => [10, ["arsse_articles.author"]], "annotationTerms" => [20, ["arsse_marks.note"]], - ] as $m => list($max, $cols)) { + ]; + foreach ($options as $m => list($max, $cols)) { if (!$context->$m()) { continue; } elseif (!$context->$m) { @@ -1288,6 +1290,17 @@ class Database { } $q->setWhere(...$this->generateSearch($context->$m, $cols)); } + // further handle exclusionary text-matching context options + foreach ($options as $m => list($max, $cols)) { + if (!$context->not->$m()) { + continue; + } elseif (!$context->not->$m) { + continue; + } elseif (sizeof($context->not->$m) > $max) { + throw new Db\ExceptionInput("tooLong", ['field' => "$m (not)", 'action' => $this->caller(), 'max' => $max]); + } + $q->setWhereNot(...$this->generateSearch($context->not->$m, $cols, true)); + } // return the query return $q; } @@ -1503,7 +1516,7 @@ class Database { * * @param string $user The user whose labels are to be listed * @param integer $id The numeric identifier of the article whose labels are to be listed - * @param boolean $byName Whether to return the label names instead of the numeric label identifiers + * @param boolean $byName Whether to return the label names (true) instead of the numeric label identifiers (false) */ public function articleLabelsGet(string $user, $id, bool $byName = false): array { if (!Arsse::$user->authorize($user, __FUNCTION__)) { @@ -1903,7 +1916,7 @@ class Database { * @param integer|string $id The numeric identifier or name of the label to validate * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) * @param boolean $checkDb Whether to check whether the label exists (true) or only if the identifier or name is syntactically valid (false) - * @param boolean $subject Whether the label is the subject rather than the object of the operation being performed; this only affects the semantics of the error message if validation fails + * @param boolean $subject Whether the label is the subject (true) rather than the object (false) of the operation being performed; this only affects the semantics of the error message if validation fails */ protected function labelValidateId(string $user, $id, bool $byName, bool $checkDb = true, bool $subject = false): array { if (!$byName && !ValueInfo::id($id)) { diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 85b4c22..7e2b1e4 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -452,6 +452,7 @@ trait SeriesArticle { "Folder tree 1 excluding articles 7 and 8" => [(new Context)->folder(1)->not->articles([7,8]), [5,6]], "Folder tree 1 excluding no articles" => [(new Context)->folder(1)->not->articles([]), [5,6,7,8]], "Marked or labelled between 2000 and 2015 excluding in 2010" => [(new Context)->markedSince("2000-01-01T00:00:00Z")->notMarkedSince("2015-12-31T23:59:59")->not->markedSince("2010-01-01T00:00:00Z")->not->notMarkedSince("2010-12-31T23:59:59Z"), [1,3,5,7,8]], + "Search with exclusion" => [(new Context)->searchTerms(["Article"])->not->searchTerms(["one", "two"]), [3]], ]; } From 1e7724ec80f6d435a768b8ebb509dbe8f46c1d03 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 26 Feb 2019 12:54:27 -0500 Subject: [PATCH 023/142] Filter out duplicates in set context options --- lib/Context/ExclusionContext.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/Context/ExclusionContext.php b/lib/Context/ExclusionContext.php index 9fc2381..d5299fe 100644 --- a/lib/Context/ExclusionContext.php +++ b/lib/Context/ExclusionContext.php @@ -77,7 +77,7 @@ class ExclusionContext { $spec[$a] = 0; } } - return array_values(array_filter($spec)); + return array_values(array_unique(array_filter($spec))); } protected function cleanStringArray(array $spec): array { @@ -90,7 +90,7 @@ class ExclusionContext { unset($spec[$a]); } } - return array_values($spec); + return array_values(array_unique($spec)); } public function folder(int $spec = null) { From 95de375e0b177cba4f1ed0f0118a80b4734f3146 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 27 Feb 2019 10:48:11 -0500 Subject: [PATCH 024/142] Handle folder and label exclusion Consequently the way label data are retrieved was completely overhauled --- lib/Database.php | 61 ++++++++++++++++---------- tests/cases/Database/SeriesArticle.php | 2 + 2 files changed, 39 insertions(+), 24 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index a806775..61eefa5 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1118,9 +1118,7 @@ class Database { $this->labelValidateId($user, $context->label, false); } if ($context->labelName()) { - // dereference the label name to an ID - $context->label((int) $this->labelValidateId($user, $context->labelName, true)['id']); - $context->labelName(null); + $this->labelValidateId($user, $context->labelName, true); } // prepare the output column list; the column definitions are also used later $greatest = $this->db->sqlToken("greatest"); @@ -1142,7 +1140,7 @@ class Database { 'published_date' => "arsse_articles.published", 'edited_date' => "arsse_articles.edited", 'modified_date' => "arsse_articles.modified", - 'marked_date' => "$greatest(arsse_articles.modified, coalesce(arsse_marks.modified, '0001-01-01 00:00:00'), coalesce(arsse_label_members.modified, '0001-01-01 00:00:00'))", + 'marked_date' => "$greatest(arsse_articles.modified, coalesce(arsse_marks.modified, '0001-01-01 00:00:00'), coalesce(label_stats.modified, '0001-01-01 00:00:00'))", 'subscription_title' => "coalesce(arsse_subscriptions.title, arsse_feeds.title)", 'media_url' => "arsse_enclosures.url", 'media_type' => "arsse_enclosures.type", @@ -1170,17 +1168,16 @@ class Database { join arsse_feeds on arsse_subscriptions.feed = arsse_feeds.id left join arsse_marks on arsse_marks.subscription = arsse_subscriptions.id and arsse_marks.article = arsse_articles.id left join arsse_enclosures on arsse_enclosures.article = arsse_articles.id - left join arsse_label_members on arsse_label_members.subscription = arsse_subscriptions.id and arsse_label_members.article = arsse_articles.id and arsse_label_members.assigned = 1 - left join arsse_labels on arsse_labels.owner = arsse_subscriptions.owner and arsse_label_members.label = arsse_labels.id", - ["str"], - [$user] + join ( + SELECT article, max(id) as edition from arsse_editions group by article + ) as latest_editions on arsse_articles.id = latest_editions.article + left join ( + SELECT arsse_label_members.article, max(arsse_label_members.modified) as modified, sum(arsse_label_members.assigned) as assigned from arsse_label_members join arsse_labels on arsse_labels.id = arsse_label_members.label where arsse_labels.owner = ? group by arsse_label_members.article + ) as label_stats on label_stats.article = arsse_articles.id", + ["str", "str"], + [$user, $user] ); $q->setLimit($context->limit, $context->offset); - $q->setCTE("latest_editions(article,edition)", "SELECT article,max(id) from arsse_editions group by article", [], [], "join latest_editions on arsse_articles.id = latest_editions.article"); - if ($cols) { - // if there are no output columns requested we're getting a count and should not group, but otherwise we should - $q->setGroup("arsse_articles.id", "arsse_marks.note", "arsse_enclosures.url", "arsse_enclosures.type", "arsse_subscriptions.title", "arsse_feeds.title", "arsse_subscriptions.id", "arsse_marks.modified", "arsse_label_members.modified", "arsse_marks.read", "arsse_marks.starred", "latest_editions.edition"); - } // handle the simple context options $options = [ // each context array consists of a column identifier (see $colDefs above), a comparison operator, a data type, an option to pair with for BETWEEN evaluation, and an upper bound if the value is an array @@ -1202,7 +1199,6 @@ class Database { "starred" => ["starred", "=", "bool", "", 1], ]; foreach ($options as $m => list($col, $op, $type, $pair, $max)) { - if (!$context->$m()) { // context is not being used continue; @@ -1254,24 +1250,41 @@ class Database { } } // handle complex context options - if ($context->labelled()) { - // any label (true) or no label (false) - $isOrIsNot = (!$context->labelled ? "is" : "is not"); - $q->setWhere("arsse_labels.id $isOrIsNot null"); - } - if ($context->label()) { - // label ID (label names are dereferenced during input validation above) - $q->setWhere("arsse_labels.id = ?", "int", $context->label); - } if ($context->annotated()) { $comp = ($context->annotated) ? "<>" : "="; $q->setWhere("coalesce(arsse_marks.note,'') $comp ''"); } + if ($context->labelled()) { + // any label (true) or no label (false) + $op = $context->labelled ? ">" : "="; + $q->setWhere("coalesce(label_stats.assigned,0) $op 0"); + } + if ($context->label() || $context->not->label() || $context->labelName() || $context->not->labelName()) { + $q->setCTE("labelled(article,label_id,label_name)","SELECT m.article, l.id, l.name from arsse_label_members as m join arsse_labels as l on l.id = m.label where l.owner = ? and m.assigned = 1", "str", $user); + if ($context->label()) { + $q->setWhere("arsse_articles.id in (select article from labelled where label_id = ?)", "int", $context->label); + } + if ($context->not->label()) { + $q->setWhereNot("arsse_articles.id in (select article from labelled where label_id = ?)", "int", $context->not->label); + } + if ($context->labelName()) { + $q->setWhere("arsse_articles.id in (select article from labelled where label_name = ?)", "str", $context->labelName); + } + if ($context->not->labelName()) { + $q->setWhereNot("arsse_articles.id in (select article from labelled where label_name = ?)", "str", $context->not->labelName); + } + } if ($context->folder()) { // add a common table expression to list the folder and its children so that we select from the entire subtree $q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on parent = folder", "int", $context->folder); // limit subscriptions to the listed folders - $q->setWhere("arsse_subscriptions.folder in (select folder from folders)"); + $q->setWhere("coalesce(arsse_subscriptions.folder,0) in (select folder from folders)"); + } + if ($context->not->folder()) { + // add a common table expression to list the folder and its children so that we exclude from the entire subtree + $q->setCTE("folders_excluded(folder)", "SELECT ? union select id from arsse_folders join folders_excluded on parent = folder", "int", $context->not->folder); + // excluded any subscriptions in the listed folders + $q->setWhereNot("coalesce(arsse_subscriptions.folder,0) in (select folder from folders_excluded)"); } // handle text-matching context options $options = [ diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 7e2b1e4..3887d78 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -453,6 +453,8 @@ trait SeriesArticle { "Folder tree 1 excluding no articles" => [(new Context)->folder(1)->not->articles([]), [5,6,7,8]], "Marked or labelled between 2000 and 2015 excluding in 2010" => [(new Context)->markedSince("2000-01-01T00:00:00Z")->notMarkedSince("2015-12-31T23:59:59")->not->markedSince("2010-01-01T00:00:00Z")->not->notMarkedSince("2010-12-31T23:59:59Z"), [1,3,5,7,8]], "Search with exclusion" => [(new Context)->searchTerms(["Article"])->not->searchTerms(["one", "two"]), [3]], + "Excluded folder tree" => [(new Context)->not->folder(1), [1,2,3,4,19,20]], + "Excluding label ID 2" => [(new Context)->not->label(2), [2,3,4,6,7,8,19]], ]; } From 85307bc90aee6d97dd894c4840f5bf72a7e9ec8b Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 28 Feb 2019 15:31:33 -0500 Subject: [PATCH 025/142] Add parser for TTRSS search strings --- lib/REST/TinyTinyRSS/Search.php | 361 ++++++++++++++++++++ tests/cases/REST/TinyTinyRSS/TestSearch.php | 126 +++++++ tests/phpunit.xml | 1 + 3 files changed, 488 insertions(+) create mode 100644 lib/REST/TinyTinyRSS/Search.php create mode 100644 tests/cases/REST/TinyTinyRSS/TestSearch.php diff --git a/lib/REST/TinyTinyRSS/Search.php b/lib/REST/TinyTinyRSS/Search.php new file mode 100644 index 0000000..4ff634b --- /dev/null +++ b/lib/REST/TinyTinyRSS/Search.php @@ -0,0 +1,361 @@ + "unread", + "star" => "starred", + "note" => "annotated", + "pub" => "published", // TODO: not implemented + ]; + const FIELDS_TEXT = [ + "title" => "titleTerms", + "author" => "authorTerms", + "note" => "annotationTerms", + "" => "searchTerms", + ]; + + public static function parse(string $search, Context $context = null) { + // normalize the input + $search = strtolower(trim(preg_replace("<\s+>", " ", $search))); + // set initial state + $tokens = []; + $pos = -1; + $stop = strlen($search); + $state = self::STATE_BEFORE_TOKEN; + $buffer = ""; + $tag = ""; + $flag_negative = false; + $context = $context ?? new Context; + // process + try { + while (++$pos <= $stop) { + $char = @$search[$pos]; + switch ($state) { + case self::STATE_BEFORE_TOKEN: + switch ($char) { + case "": + continue 3; + case " ": + continue 3; + case '"': + if ($flag_negative) { + $buffer .= $char; + $state = self::STATE_IN_TOKEN_OR_TAG; + } else { + $state = self::STATE_BEFORE_TOKEN_QUOTED; + } + continue 3; + case "-": + if (!$flag_negative) { + $flag_negative = true; + } else { + $buffer .= $char; + $state = self::STATE_IN_TOKEN_OR_TAG; + } + continue 3; + case "@": + $state = self::STATE_IN_DATE; + continue 3; + case ":": + $state = self::STATE_IN_TOKEN; + continue 3; + default: + $buffer .= $char; + $state = self::STATE_IN_TOKEN_OR_TAG; + continue 3; + } + case self::STATE_BEFORE_TOKEN_QUOTED: + switch ($char) { + case "": + continue 3; + case '"': + if (($pos + 1 == $stop) || $search[$pos + 1] === " ") { + $context = self::processToken($context, $buffer, $tag, $flag_negative, false); + $state = self::STATE_BEFORE_TOKEN; + $flag_negative = false; + $buffer = $tag = ""; + } elseif ($search[$pos + 1] === '"') { + $buffer .= '"'; + $pos++; + $state = self::STATE_IN_TOKEN_OR_TAG_QUOTED; + } else { + $state = self::STATE_IN_TOKEN_OR_TAG; + } + continue 3; + case "\\": + if ($pos + 1 == $stop) { + $buffer .= $char; + } elseif ($search[$pos + 1] === '"') { + $buffer .= '"'; + $pos++; + } else { + $buffer .= $char; + } + $state = self::STATE_IN_TOKEN_OR_TAG_QUOTED; + continue 3; + case "-": + if (!$flag_negative) { + $flag_negative = true; + } else { + $buffer .= $char; + $state = self::STATE_IN_TOKEN_OR_TAG_QUOTED; + } + continue 3; + case "@": + $state = self::STATE_IN_DATE_QUOTED; + continue 3; + case ":": + $state = self::STATE_IN_TOKEN_QUOTED; + continue 3; + default: + $buffer .= $char; + $state = self::STATE_IN_TOKEN_OR_TAG_QUOTED; + continue 3; + } + case self::STATE_IN_DATE: + while ($pos < $stop && $search[$pos] !== " ") { + $buffer .= $search[$pos++]; + } + $context = self::processToken($context, $buffer, $tag, $flag_negative, true); + $state = self::STATE_BEFORE_TOKEN; + $flag_negative = false; + $buffer = $tag = ""; + continue 2; + case self::STATE_IN_DATE_QUOTED: + switch ($char) { + case "": + case '"': + if (($pos + 1 >= $stop) || $search[$pos + 1] === " ") { + $context = self::processToken($context, $buffer, $tag, $flag_negative, true); + $state = self::STATE_BEFORE_TOKEN; + $flag_negative = false; + $buffer = $tag = ""; + } elseif ($search[$pos + 1] === '"') { + $buffer .= '"'; + $pos++; + } else { + $state = self::STATE_IN_DATE; + } + continue 3; + case "\\": + if ($pos + 1 == $stop) { + $buffer .= $char; + } elseif ($search[$pos + 1] === '"') { + $buffer .= '"'; + $pos++; + } else { + $buffer .= $char; + } + continue 3; + default: + $buffer .= $char; + continue 3; + } + case self::STATE_IN_TOKEN: + while ($pos < $stop && $search[$pos] !== " ") { + $buffer .= $search[$pos++]; + } + if (!strlen($tag)) { + $buffer = ":".$buffer; + } + $context = self::processToken($context, $buffer, $tag, $flag_negative, false); + $state = self::STATE_BEFORE_TOKEN; + $flag_negative = false; + $buffer = $tag = ""; + continue 2; + case self::STATE_IN_TOKEN_QUOTED: + switch ($char) { + case "": + case '"': + if (($pos + 1 >= $stop) || $search[$pos + 1] === " ") { + if (!strlen($tag)) { + $buffer = ":".$buffer; + } + $context = self::processToken($context, $buffer, $tag, $flag_negative, false); + $state = self::STATE_BEFORE_TOKEN; + $flag_negative = false; + $buffer = $tag = ""; + } elseif ($search[$pos + 1] === '"') { + $buffer .= '"'; + $pos++; + } else { + $state = self::STATE_IN_TOKEN; + } + continue 3; + case "\\": + if ($pos + 1 == $stop) { + $buffer .= $char; + } elseif ($search[$pos + 1] === '"') { + $buffer .= '"'; + $pos++; + } else { + $buffer .= $char; + } + continue 3; + default: + $buffer .= $char; + continue 3; + } + case self::STATE_IN_TOKEN_OR_TAG: + switch ($char) { + case "": + case " ": + $context = self::processToken($context, $buffer, $tag, $flag_negative, false); + $state = self::STATE_BEFORE_TOKEN; + $flag_negative = false; + $buffer = $tag = ""; + continue 3; + case ":"; + $tag = $buffer; + $buffer = ""; + $state = self::STATE_IN_TOKEN; + continue 3; + default: + $buffer .= $char; + continue 3; + } + case self::STATE_IN_TOKEN_OR_TAG_QUOTED: + switch ($char) { + case "": + case '"': + if (($pos + 1 >= $stop) || $search[$pos + 1] === " ") { + $context = self::processToken($context, $buffer, $tag, $flag_negative, false); + $state = self::STATE_BEFORE_TOKEN; + $flag_negative = false; + $buffer = $tag = ""; + } elseif ($search[$pos + 1] === '"') { + $buffer .= '"'; + $pos++; + } else { + $state = self::STATE_IN_TOKEN_OR_TAG; + } + continue 3; + case "\\": + if ($pos + 1 == $stop) { + $buffer .= $char; + } elseif ($search[$pos + 1] === '"') { + $buffer .= '"'; + $pos++; + } else { + $buffer .= $char; + } + continue 3; + case ":": + $tag = $buffer; + $buffer = ""; + $state = self::STATE_IN_TOKEN_QUOTED; + continue 3; + default: + $buffer .= $char; + continue 3; + } + default: + throw new \Exception; // @codeCoverageIgnore + } + } + } catch (Exception $e) { + return null; + } + return $context; + } + + protected static function processToken(Context $c, string $value, string $tag, bool $neg, bool $date): Context { + if (!strlen($value) && !strlen($tag)) { + return $c; + } elseif (!strlen($value)) { + // if a tag has an empty value, the tag is treated as a search term instead + $value = "$tag:"; + $tag = ""; + } + if ($date) { + return self::setDate($value, $c, $neg); + } elseif (isset(self::FIELDS_BOOLEAN[$tag])) { + return self::setBoolean($tag, $value, $c, $neg); + } else { + return self::addTerm($tag, $value, $c, $neg); + } + } + + protected static function addTerm(string $tag, string $value, Context $c, bool $neg): Context { + $c = $neg ? $c->not : $c; + $type = self::FIELDS_TEXT[$tag] ?? ""; + if (!$type) { + $value = "$tag:$value"; + $type = self::FIELDS_TEXT[""]; + } + return $c->$type(array_merge($c->$type ?? [], [$value])); + } + + protected static function setDate(string $value, Context $c, bool $neg): Context { + $spec = Date::normalize($value); + // TTRSS treats invalid dates as the start of the Unix epoch; we ignore them instead + if (!$spec) { + return $c; + } + $day = $spec->format("Y-m-d"); + $start = $day."T00:00:00+00:00"; + $end = $day."T23:59:59+00:00"; + // if a date is already set, the same date is a no-op; anything else is a contradiction + $cc = $neg ? $c->not : $c; + if ($cc->modifiedSince() || $cc->notModifiedSince()) { + if (!$cc->modifiedSince() || !$cc->notModifiedSince() || $cc->modifiedSince->format("c") !== $start || $cc->notModifiedSince->format("c") !== $end) { + // FIXME: multiple negative dates should be allowed, but the design of the Context class does not support this + throw new Exception; + } else { + return $c; + } + } + $cc->modifiedSince($start); + $cc->notModifiedSince($end); + return $c; + } + + protected static function setBoolean(string $tag, string $value, Context $c, bool $neg): Context { + $set = ["true" => true, "false" => false][$value] ?? null; + if (is_null($set)) { + return self::addTerm($tag, $value, $c, $neg); + } else { + // apply negation + $set = $neg ? !$set : $set; + if ($tag === "pub") { + // TODO: this needs to be implemented correctly if the Published feed is implemented + // currently specifying true will always yield an empty result (nothing is ever published), and specifying false is a no-op (matches everything) + if ($set) { + throw new Exception; + } else { + return $c; + } + } else { + $field = (self::FIELDS_BOOLEAN[$tag] ?? ""); + if (!$c->$field()) { + // field has not yet been set; set it + return $c->$field($set); + } elseif ($c->$field == $set) { + // field is already set to same value; do nothing + return $c; + } else { + // contradiction: query would return no results + throw new Exception; + } + } + } + } +} diff --git a/tests/cases/REST/TinyTinyRSS/TestSearch.php b/tests/cases/REST/TinyTinyRSS/TestSearch.php new file mode 100644 index 0000000..62ad553 --- /dev/null +++ b/tests/cases/REST/TinyTinyRSS/TestSearch.php @@ -0,0 +1,126 @@ + ["", new Context], + 'Whitespace only' => [" \n \t", new Context], + 'Simple bare token' => ['OOK', (new Context)->searchTerms(["ook"])], + 'Simple negative bare token' => ['-OOK', (new Context)->not->searchTerms(["ook"])], + 'Simple quoted token' => ['"OOK eek"', (new Context)->searchTerms(["ook eek"])], + 'Simple negative quoted token' => ['"-OOK eek"', (new Context)->not->searchTerms(["ook eek"])], + 'Simple bare tokens' => ['OOK eek', (new Context)->searchTerms(["ook", "eek"])], + 'Simple mixed bare tokens' => ['-OOK eek', (new Context)->not->searchTerms(["ook"])->searchTerms(["eek"])], + 'Unclosed quoted token' => ['"OOK eek', (new Context)->searchTerms(["ook eek"])], + 'Unclosed quoted token 2' => ['"OOK eek" "', (new Context)->searchTerms(["ook eek"])], + 'Broken quoted token 1' => ['"-OOK"eek"', (new Context)->not->searchTerms(["ookeek\""])], + 'Broken quoted token 2' => ['""eek"', (new Context)->searchTerms(["eek\""])], + 'Broken quoted token 3' => ['"-"eek"', (new Context)->not->searchTerms(["eek\""])], + 'Empty quoted token' => ['""', new Context], + 'Simple quoted tokens' => ['"OOK eek" "eek ack"', (new Context)->searchTerms(["ook eek", "eek ack"])], + 'Bare blank tag' => [':ook', (new Context)->searchTerms([":ook"])], + 'Quoted blank tag' => ['":ook"', (new Context)->searchTerms([":ook"])], + 'Bare negative blank tag' => ['-:ook', (new Context)->not->searchTerms([":ook"])], + 'Quoted negative blank tag' => ['"-:ook"', (new Context)->not->searchTerms([":ook"])], + 'Bare valueless blank tag' => [':', (new Context)->searchTerms([":"])], + 'Quoted valueless blank tag' => ['":"', (new Context)->searchTerms([":"])], + 'Bare negative valueless blank tag' => ['-:', (new Context)->not->searchTerms([":"])], + 'Quoted negative valueless blank tag' => ['"-:"', (new Context)->not->searchTerms([":"])], + 'Double negative' => ['--eek', (new Context)->not->searchTerms(["-eek"])], + 'Double negative 2' => ['--@eek', (new Context)->not->searchTerms(["-@eek"])], + 'Double negative 3' => ['"--@eek"', (new Context)->not->searchTerms(["-@eek"])], + 'Double negative 4' => ['"--eek"', (new Context)->not->searchTerms(["-eek"])], + 'Negative before quote' => ['-"ook"', (new Context)->not->searchTerms(["\"ook\""])], + 'Bare unread tag true' => ['UNREAD:true', (new Context)->unread(true)], + 'Bare unread tag false' => ['UNREAD:false', (new Context)->unread(false)], + 'Bare negative unread tag true' => ['-unread:true', (new Context)->unread(false)], + 'Bare negative unread tag false' => ['-unread:false', (new Context)->unread(true)], + 'Quoted unread tag true' => ['"UNREAD:true"', (new Context)->unread(true)], + 'Quoted unread tag false' => ['"UNREAD:false"', (new Context)->unread(false)], + 'Quoted negative unread tag true' => ['"-unread:true"', (new Context)->unread(false)], + 'Quoted negative unread tag false' => ['"-unread:false"', (new Context)->unread(true)], + 'Bare star tag true' => ['STAR:true', (new Context)->starred(true)], + 'Bare star tag false' => ['STAR:false', (new Context)->starred(false)], + 'Bare negative star tag true' => ['-star:true', (new Context)->starred(false)], + 'Bare negative star tag false' => ['-star:false', (new Context)->starred(true)], + 'Quoted star tag true' => ['"STAR:true"', (new Context)->starred(true)], + 'Quoted star tag false' => ['"STAR:false"', (new Context)->starred(false)], + 'Quoted negative star tag true' => ['"-star:true"', (new Context)->starred(false)], + 'Quoted negative star tag false' => ['"-star:false"', (new Context)->starred(true)], + 'Bare note tag true' => ['NOTE:true', (new Context)->annotated(true)], + 'Bare note tag false' => ['NOTE:false', (new Context)->annotated(false)], + 'Bare negative note tag true' => ['-note:true', (new Context)->annotated(false)], + 'Bare negative note tag false' => ['-note:false', (new Context)->annotated(true)], + 'Quoted note tag true' => ['"NOTE:true"', (new Context)->annotated(true)], + 'Quoted note tag false' => ['"NOTE:false"', (new Context)->annotated(false)], + 'Quoted negative note tag true' => ['"-note:true"', (new Context)->annotated(false)], + 'Quoted negative note tag false' => ['"-note:false"', (new Context)->annotated(true)], + 'Bare pub tag true' => ['PUB:true', null], + 'Bare pub tag false' => ['PUB:false', new Context], + 'Bare negative pub tag true' => ['-pub:true', new Context], + 'Bare negative pub tag false' => ['-pub:false', null], + 'Quoted pub tag true' => ['"PUB:true"', null], + 'Quoted pub tag false' => ['"PUB:false"', new Context], + 'Quoted negative pub tag true' => ['"-pub:true"', new Context], + 'Quoted negative pub tag false' => ['"-pub:false"', null], + 'Non-boolean unread tag' => ['unread:maybe', (new Context)->searchTerms(["unread:maybe"])], + 'Non-boolean star tag' => ['star:maybe', (new Context)->searchTerms(["star:maybe"])], + 'Non-boolean pub tag' => ['pub:maybe', (new Context)->searchTerms(["pub:maybe"])], + 'Non-boolean note tag' => ['note:maybe', (new Context)->annotationTerms(["maybe"])], + 'Valueless unread tag' => ['unread:', (new Context)->searchTerms(["unread:"])], + 'Valueless star tag' => ['star:', (new Context)->searchTerms(["star:"])], + 'Valueless pub tag' => ['pub:', (new Context)->searchTerms(["pub:"])], + 'Valueless note tag' => ['note:', (new Context)->searchTerms(["note:"])], + 'Valueless title tag' => ['title:', (new Context)->searchTerms(["title:"])], + 'Valueless author tag' => ['author:', (new Context)->searchTerms(["author:"])], + 'Escaped quote 1' => ['"""I say, Jeeves!"""', (new Context)->searchTerms(["\"i say, jeeves!\""])], + 'Escaped quote 2' => ['"\\"I say, Jeeves!\\""', (new Context)->searchTerms(["\"i say, jeeves!\""])], + 'Escaped quote 3' => ['\\"I say, Jeeves!\\"', (new Context)->searchTerms(["\\\"i", "say,", "jeeves!\\\""])], + 'Escaped quote 4' => ['"\\"\\I say, Jeeves!\\""', (new Context)->searchTerms(["\"\\i say, jeeves!\""])], + 'Escaped quote 5' => ['"\\I say, Jeeves!"', (new Context)->searchTerms(["\\i say, jeeves!"])], + 'Escaped quote 6' => ['"\\"I say, Jeeves!\\', (new Context)->searchTerms(["\"i say, jeeves!\\"])], + 'Escaped quote 7' => ['"\\', (new Context)->searchTerms(["\\"])], + 'Quoted author tag 1' => ['"author:Neal Stephenson"', (new Context)->authorTerms(["neal stephenson"])], + 'Quoted author tag 2' => ['"author:Jo ""Cap\'n Tripps"" Ashburn"', (new Context)->authorTerms(["jo \"cap'n tripps\" ashburn"])], + 'Quoted author tag 3' => ['"author:Jo \\"Cap\'n Tripps\\" Ashburn"', (new Context)->authorTerms(["jo \"cap'n tripps\" ashburn"])], + 'Quoted author tag 4' => ['"author:Jo ""Cap\'n Tripps"Ashburn"', (new Context)->authorTerms(["jo \"cap'n trippsashburn\""])], + 'Quoted author tag 5' => ['"author:Jo ""Cap\'n Tripps\ Ashburn"', (new Context)->authorTerms(["jo \"cap'n tripps\\ ashburn"])], + 'Quoted author tag 6' => ['"author:Neal Stephenson\\', (new Context)->authorTerms(["neal stephenson\\"])], + 'Quoted title tag' => ['"title:Generic title"', (new Context)->titleTerms(["generic title"])], + 'Contradictory booleans' => ['unread:true -unread:true', null], + 'Doubled boolean' => ['unread:true unread:true', (new Context)->unread(true)], + 'Bare blank date' => ['@', new Context], + 'Quoted blank date' => ['"@"', new Context], + 'Bare ISO date' => ['@2019-03-01', (new Context)->modifiedSince("2019-03-01T00:00:00Z")->notModifiedSince("2019-03-01T23:59:59Z")], + 'Quoted ISO date' => ['"@March 1st, 2019"', (new Context)->modifiedSince("2019-03-01T00:00:00Z")->notModifiedSince("2019-03-01T23:59:59Z")], + 'Bare negative ISO date' => ['-@2019-03-01', (new Context)->not->modifiedSince("2019-03-01T00:00:00Z")->not->notModifiedSince("2019-03-01T23:59:59Z")], + 'Quoted negative English date' => ['"-@March 1st, 2019"', (new Context)->not->modifiedSince("2019-03-01T00:00:00Z")->not->notModifiedSince("2019-03-01T23:59:59Z")], + 'Invalid date' => ['@Bugaboo', new Context], + 'Escaped quoted date 1' => ['"@""Yesterday" and today', (new Context)->searchTerms(["and", "today"])], + 'Escaped quoted date 2' => ['"@\\"Yesterday" and today', (new Context)->searchTerms(["and", "today"])], + 'Escaped quoted date 3' => ['"@Yesterday\\', new Context], + 'Escaped quoted date 4' => ['"@Yesterday\\and today', new Context], + 'Escaped quoted date 5' => ['"@Yesterday"and today', (new Context)->searchTerms(["today"])], + 'Contradictory dates' => ['@Yesterday @Today', null], + 'Doubled date' => ['"@March 1st, 2019" @2019-03-01', (new Context)->modifiedSince("2019-03-01T00:00:00Z")->notModifiedSince("2019-03-01T23:59:59Z")], + 'Doubled negative date' => ['"-@March 1st, 2019" -@2019-03-01', (new Context)->not->modifiedSince("2019-03-01T00:00:00Z")->not->notModifiedSince("2019-03-01T23:59:59Z")], + ]; + } + + /** @dataProvider provideSearchStrings */ + public function testApplySearchToContext(string $search, $exp) { + $act = Search::parse($search); + //var_export($act); + $this->assertEquals($exp, $act); + } +} diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 65a0893..aac033b 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -99,6 +99,7 @@ cases/REST/NextCloudNews/PDO/TestV1_2.php + cases/REST/TinyTinyRSS/TestSearch.php cases/REST/TinyTinyRSS/TestAPI.php cases/REST/TinyTinyRSS/TestIcon.php cases/REST/TinyTinyRSS/PDO/TestAPI.php From 3b8461b1ca4ba0e5cac8195493052cca94f92874 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 28 Feb 2019 16:22:04 -0500 Subject: [PATCH 026/142] Add searching to TTRSS handler --- CHANGELOG | 6 ++++++ README.md | 8 +++++++- lib/REST/TinyTinyRSS/API.php | 13 ++++++++++--- tests/cases/REST/TinyTinyRSS/TestAPI.php | 5 +++++ 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 10832aa..af5159f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,9 @@ +Version 0.7.0 (2019-??-??) +========================== + +New features: +- Support for basic freeform searching in Tiny Tiny RSS + Version 0.6.1 (2019-01-23) ========================== diff --git a/README.md b/README.md index 2cec044..d4fca7f 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,6 @@ We are not aware of any other extensions to the TTRSS protocol. If you know of a - The `getPref` operation is not implemented; it returns `UNKNOWN_METHOD` - The `shareToPublished` operation is not implemented; it returns `UNKNOWN_METHOD` - Setting an article's "published" flag with the `updateArticle` operation is not implemented and will gracefully fail -- The `search` parameter of the `getHeadlines` operation is not implemented; the operation will proceed as if no search string were specified - The `sanitize`, `force_update`, and `has_sandbox` parameters of the `getHeadlines` operation are ignored - String `feed_id` values for the `getCompactHeadlines` operation are not supported and will yield an `INCORRECT_USAGE` error - Articles are limited to a single attachment rather than multiple attachments @@ -141,6 +140,13 @@ We are not aware of any other extensions to the TTRSS protocol. If you know of a - Feed, category, and label names are normally unrestricted; The Arsse rejects empty strings, as well as strings composed solely of whitespace - Discovering multiple feeds during `subscribeToFeed` processing normally produces an error; The Arsse instead chooses the first feed it finds - Providing the `setArticleLabel` operation with an invalid label normally silently fails; The Arsse returns an `INVALID_USAGE` error instead +- Processing of the `search` parameter of the `getHeadlines` operation differs in the following ways: + - Values other than `"true"` or `"false"` for the `unread`, `star`, and `pub` special keywords treat the entire token as a search term rather than as `"false"` + - Limits are placed on the number of search terms: ten each for `title`, `author`, and `note`, and twenty for content searching; exceeding the limits will yield a non-standard `TOO_MANY_SEARCH_TERMS` error + - Invalid dates are ignored rather than assumed to be `"1970-01-01"` + - Only a single negative date is allowed (this is a known bug rather than intentional) + - Dates are always relative to UTC + - Full-text search is not yet employed with any database, including PostgreSQL - Article hashes are normally SHA1; The Arsse uses SHA256 hashes - Article attachments normally have unique IDs; The Arsse always gives attachments an ID of `"0"` - The default sort order of the `getHeadlines` operation normally uses custom sorting for "special" feeds; The Arsse's default sort order is equivalent to `feed_dates` for all feeds diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index f126324..a3572ba 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -49,7 +49,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 'sid' => ValueInfo::T_STRING, // session ID 'seq' => ValueInfo::T_INT, // request number from client 'user' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // user name for `login` - 'password' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // password for `login` and `subscribeToFeed` + 'password' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // password for `login` or remote password for `subscribeToFeed` 'include_empty' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to include empty items in `getFeedTree` and `getCategories` 'unread_only' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to exclude items without unread articles in `getCategories` and `getFeeds` 'enable_nested' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to NOT show subcategories in `getCategories @@ -76,7 +76,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 'since_id' => ValueInfo::T_INT, // cut-off article ID for `getHeadlines` and `getCompactHeadlines; returns only higher article IDs when specified 'order_by' => ValueInfo::T_STRING, // sort order for `getHeadlines` 'include_header' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to attach a header to the results of `getHeadlines` - 'search' => ValueInfo::T_STRING, // search string for `getHeadlines` (not yet implemented) + 'search' => ValueInfo::T_STRING, // search string for `getHeadlines` 'field' => ValueInfo::T_INT, // which state to change in `updateArticle` 'mode' => ValueInfo::T_INT, // whether to set, clear, or toggle the selected state in `updateArticle` 'data' => ValueInfo::T_STRING, // note text in `updateArticle` if setting a note @@ -1478,7 +1478,14 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { default: throw new \JKingWeb\Arsse\Exception("constantUnknown", $viewMode); // @codeCoverageIgnore } - // TODO: implement searching + // handle the search string, if any + if (isset($data['search'])) { + $c = Search::parse($data['search'], $c); + if (!$c) { + // the search string inherently returns an empty result, either directly or interacting with other input + return new ResultEmpty; + } + } // handle sorting switch ($data['order_by']) { case "date_reverse": diff --git a/tests/cases/REST/TinyTinyRSS/TestAPI.php b/tests/cases/REST/TinyTinyRSS/TestAPI.php index 4b497a7..91b370c 100644 --- a/tests/cases/REST/TinyTinyRSS/TestAPI.php +++ b/tests/cases/REST/TinyTinyRSS/TestAPI.php @@ -1809,6 +1809,8 @@ LONG_STRING; ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'view_mode' => "published"], ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -6, 'view_mode' => "unread"], ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 2112], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'view_mode' => "unread", 'search' => "unread:false"], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'search' => "pub:true"], ]; $in2 = [ // simple context tests @@ -1833,6 +1835,7 @@ LONG_STRING; ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'is_cat' => true, 'include_nested' => true], ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'order_by' => "feed_dates"], ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'order_by' => "date_reverse"], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'search' => "interesting"], ]; $in3 = [ // time-based context tests @@ -1868,6 +1871,7 @@ LONG_STRING; Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folderShallow(42), $this->anything())->thenReturn($this->generateHeadlines(14)); Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folder(42), $this->anything())->thenReturn($this->generateHeadlines(15)); Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->reverse(false), $this->anything())->thenReturn($this->generateHeadlines(16)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->searchTerms(["interesting"]), $this->anything())->thenReturn($this->generateHeadlines(17)); $out2 = [ $this->respErr("INCORRECT_USAGE"), $this->outputHeadlines(11), @@ -1890,6 +1894,7 @@ LONG_STRING; $this->outputHeadlines(15), $this->outputHeadlines(11), // defaulting sorting is not fully implemented $this->outputHeadlines(16), + $this->outputHeadlines(17), ]; $out3 = [ $this->outputHeadlines(1001), From 837f3c6dd64cc4a2d167dd9bfbc1820846c2b802 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 1 Mar 2019 12:17:33 -0500 Subject: [PATCH 027/142] Simplify SQL type handling This is done in anticipation of dealing with SQL types in places other than statements --- lib/Db/AbstractStatement.php | 62 +++++++++++++++----------------- lib/Db/MySQL/Statement.php | 18 +++++----- lib/Db/PDOStatement.php | 14 ++++---- lib/Db/PostgreSQL/Statement.php | 16 ++++----- lib/Db/SQLite3/Statement.php | 14 ++++---- lib/Db/Statement.php | 58 +++++++++++++++++++++--------- tests/cases/Db/BaseStatement.php | 12 +++---- 7 files changed, 107 insertions(+), 87 deletions(-) diff --git a/lib/Db/AbstractStatement.php b/lib/Db/AbstractStatement.php index 1dc990f..abf9f77 100644 --- a/lib/Db/AbstractStatement.php +++ b/lib/Db/AbstractStatement.php @@ -12,11 +12,25 @@ use JKingWeb\Arsse\Misc\ValueInfo; abstract class AbstractStatement implements Statement { use SQLState; + const TYPE_NORM_MAP = [ + self::T_INTEGER => ValueInfo::M_NULL | ValueInfo::T_INT, + self::T_STRING => ValueInfo::M_NULL | ValueInfo::T_STRING, + self::T_BOOLEAN => ValueInfo::M_NULL | ValueInfo::T_BOOL, + self::T_DATETIME => ValueInfo::M_NULL | ValueInfo::T_DATE, + self::T_FLOAT => ValueInfo::M_NULL | ValueInfo::T_FLOAT, + self::T_BINARY => ValueInfo::M_NULL | ValueInfo::T_STRING, + self::T_NOT_NULL + self::T_INTEGER => ValueInfo::T_INT, + self::T_NOT_NULL + self::T_STRING => ValueInfo::T_STRING, + self::T_NOT_NULL + self::T_BOOLEAN => ValueInfo::T_BOOL, + self::T_NOT_NULL + self::T_DATETIME => ValueInfo::T_DATE, + self::T_NOT_NULL + self::T_FLOAT => ValueInfo::T_FLOAT, + self::T_NOT_NULL + self::T_BINARY => ValueInfo::T_STRING, + ]; + protected $types = []; - protected $isNullable = []; abstract public function runArray(array $values = []): Result; - abstract protected function bindValue($value, string $type, int $position): bool; + abstract protected function bindValue($value, int $type, int $position): bool; abstract protected function prepare(string $query): bool; abstract protected static function buildEngineException($code, string $msg): array; @@ -41,18 +55,11 @@ abstract class AbstractStatement implements Statement { // recursively flatten any arrays, which may be provided for SET or IN() clauses $this->retypeArray($binding, true); } else { - $binding = trim(strtolower($binding)); - if (strpos($binding, "strict ")===0) { - // "strict" types' values may never be null; null values will later be cast to the type specified - $this->isNullable[] = false; - $binding = substr($binding, 7); - } else { - $this->isNullable[] = true; - } - if (!array_key_exists($binding, self::TYPES)) { + $bindId = self::TYPES[trim(strtolower($binding))] ?? 0; + if (!$bindId) { throw new Exception("paramTypeInvalid", $binding); // @codeCoverageIgnore } - $this->types[] = self::TYPES[$binding]; + $this->types[] = $bindId; } } if (!$append) { @@ -61,27 +68,16 @@ abstract class AbstractStatement implements Statement { return true; } - protected function cast($v, string $t, bool $nullable) { + protected function cast($v, int $t) { switch ($t) { - case "datetime": + case self::T_DATETIME: + return Date::transform($v, "sql"); + case self::T_DATETIME + self::T_NOT_NULL: $v = Date::transform($v, "sql"); - if (is_null($v) && !$nullable) { - $v = 0; - $v = Date::transform($v, "sql"); - } - return $v; - case "integer": - return ValueInfo::normalize($v, ValueInfo::T_INT | ($nullable ? ValueInfo::M_NULL : 0), null, "sql"); - case "float": - return ValueInfo::normalize($v, ValueInfo::T_FLOAT | ($nullable ? ValueInfo::M_NULL : 0), null, "sql"); - case "binary": - case "string": - return ValueInfo::normalize($v, ValueInfo::T_STRING | ($nullable ? ValueInfo::M_NULL : 0), null, "sql"); - case "boolean": - $v = ValueInfo::normalize($v, ValueInfo::T_BOOL | ($nullable ? ValueInfo::M_NULL : 0), null, "sql"); - return is_null($v) ? $v : (int) $v; + return $v ? $v : "0001-01-01 00:00:00"; default: - throw new Exception("paramTypeUnknown", $type); // @codeCoverageIgnore + $v = ValueInfo::normalize($v, self::TYPE_NORM_MAP[$t], null, "sql"); + return is_bool($v) ? (int) $v : $v; } } @@ -92,8 +88,8 @@ abstract class AbstractStatement implements Statement { // recursively flatten any arrays, which may be provided for SET or IN() clauses $a += $this->bindValues($value, $a); } elseif (array_key_exists($a, $this->types)) { - $value = $this->cast($value, $this->types[$a], $this->isNullable[$a]); - $this->bindValue($value, $this->types[$a], ++$a); + $value = $this->cast($value, $this->types[$a]); + $this->bindValue($value, $this->types[$a] % self::T_NOT_NULL, ++$a); } else { throw new Exception("paramTypeMissing", $a+1); } @@ -102,7 +98,7 @@ abstract class AbstractStatement implements Statement { // SQLite will happily substitute null for a missing value, but other engines (viz. PostgreSQL) produce an error if (is_null($offset)) { while ($a < sizeof($this->types)) { - $this->bindValue(null, $this->types[$a], ++$a); + $this->bindValue(null, $this->types[$a] % self::T_NOT_NULL, ++$a); } } return $a - $offset; diff --git a/lib/Db/MySQL/Statement.php b/lib/Db/MySQL/Statement.php index 9612615..acbf4a5 100644 --- a/lib/Db/MySQL/Statement.php +++ b/lib/Db/MySQL/Statement.php @@ -14,12 +14,12 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { use ExceptionBuilder; const BINDINGS = [ - "integer" => "i", - "float" => "d", - "datetime" => "s", - "binary" => "b", - "string" => "s", - "boolean" => "i", + self::T_INTEGER => "i", + self::T_FLOAT => "d", + self::T_DATETIME => "s", + self::T_BINARY => "b", + self::T_STRING => "s", + self::T_BOOLEAN => "i", ]; protected $db; @@ -93,11 +93,11 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { return new Result($r, [$changes, $lastId], $this); } - protected function bindValue($value, string $type, int $position): bool { + protected function bindValue($value, int $type, int $position): bool { // this is a bit of a hack: we collect values (and MySQL bind types) here so that we can take // advantage of the work done by bindValues() even though MySQL requires everything to be bound // all at once; we also segregate large values for later packetization - if (($type === "binary" && !is_null($value)) || (is_string($value) && strlen($value) > $this->packetSize)) { + if (($type == self::T_BINARY && !is_null($value)) || (is_string($value) && strlen($value) > $this->packetSize)) { $this->values[] = null; $this->longs[$position - 1] = $value; $this->binds .= "b"; @@ -112,7 +112,7 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { $out = ""; for ($b = 1; $b < sizeof($query); $b++) { $a = $b - 1; - $mark = (($types[$a] ?? "") === "datetime") ? "cast(? as datetime(0))" : "?"; + $mark = (($types[$a] ?? 0) % self::T_NOT_NULL == self::T_DATETIME) ? "cast(? as datetime(0))" : "?"; $out .= $query[$a].$mark; } $out .= array_pop($query); diff --git a/lib/Db/PDOStatement.php b/lib/Db/PDOStatement.php index 594ecf8..2175231 100644 --- a/lib/Db/PDOStatement.php +++ b/lib/Db/PDOStatement.php @@ -10,12 +10,12 @@ abstract class PDOStatement extends AbstractStatement { use PDOError; const BINDINGS = [ - "integer" => \PDO::PARAM_INT, - "float" => \PDO::PARAM_STR, - "datetime" => \PDO::PARAM_STR, - "binary" => \PDO::PARAM_LOB, - "string" => \PDO::PARAM_STR, - "boolean" => \PDO::PARAM_INT, // FIXME: using \PDO::PARAM_BOOL leads to incompatibilities with versions of SQLite bundled prior to PHP 7.3 + self::T_INTEGER => \PDO::PARAM_INT, + self::T_FLOAT => \PDO::PARAM_STR, + self::T_DATETIME => \PDO::PARAM_STR, + self::T_BINARY => \PDO::PARAM_LOB, + self::T_STRING => \PDO::PARAM_STR, + self::T_BOOLEAN => \PDO::PARAM_INT, // FIXME: using \PDO::PARAM_BOOL leads to incompatibilities with versions of SQLite bundled prior to PHP 7.3 ]; protected $st; @@ -55,7 +55,7 @@ abstract class PDOStatement extends AbstractStatement { return new PDOResult($this->db, $this->st); } - protected function bindValue($value, string $type, int $position): bool { + protected function bindValue($value, int $type, int $position): bool { return $this->st->bindValue($position, $value, is_null($value) ? \PDO::PARAM_NULL : self::BINDINGS[$type]); } } diff --git a/lib/Db/PostgreSQL/Statement.php b/lib/Db/PostgreSQL/Statement.php index df74e3d..f5040f2 100644 --- a/lib/Db/PostgreSQL/Statement.php +++ b/lib/Db/PostgreSQL/Statement.php @@ -14,12 +14,12 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { use Dispatch; const BINDINGS = [ - "integer" => "bigint", - "float" => "decimal", - "datetime" => "timestamp(0) without time zone", - "binary" => "bytea", - "string" => "text", - "boolean" => "smallint", // FIXME: using boolean leads to incompatibilities with versions of SQLite bundled prior to PHP 7.3 + self::T_INTEGER => "bigint", + self::T_FLOAT => "decimal", + self::T_DATETIME => "timestamp(0) without time zone", + self::T_BINARY => "bytea", + self::T_STRING => "text", + self::T_BOOLEAN => "smallint", // FIXME: using boolean leads to incompatibilities with versions of SQLite bundled prior to PHP 7.3 ]; protected $db; @@ -47,7 +47,7 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { } } - protected function bindValue($value, string $type, int $position): bool { + protected function bindValue($value, int $type, int $position): bool { $this->in[] = $value; return true; } @@ -59,7 +59,7 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { for ($b = 1; $b < sizeof($q); $b++) { $a = $b - 1; $mark = $mungeParamMarkers ? "\$$b" : "?"; - $type = isset($types[$a]) ? "::".self::BINDINGS[$types[$a]] : ""; + $type = isset($types[$a]) ? "::".self::BINDINGS[$types[$a] % self::T_NOT_NULL] : ""; $out .= $q[$a].$mark.$type; } $out .= array_pop($q); diff --git a/lib/Db/SQLite3/Statement.php b/lib/Db/SQLite3/Statement.php index a0fb0cd..bfae44d 100644 --- a/lib/Db/SQLite3/Statement.php +++ b/lib/Db/SQLite3/Statement.php @@ -17,12 +17,12 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { const SQLITE_CONSTRAINT = 19; const SQLITE_MISMATCH = 20; const BINDINGS = [ - "integer" => \SQLITE3_INTEGER, - "float" => \SQLITE3_FLOAT, - "datetime" => \SQLITE3_TEXT, - "binary" => \SQLITE3_BLOB, - "string" => \SQLITE3_TEXT, - "boolean" => \SQLITE3_INTEGER, + self::T_INTEGER => \SQLITE3_INTEGER, + self::T_FLOAT => \SQLITE3_FLOAT, + self::T_DATETIME => \SQLITE3_TEXT, + self::T_BINARY => \SQLITE3_BLOB, + self::T_STRING => \SQLITE3_TEXT, + self::T_BOOLEAN => \SQLITE3_INTEGER, ]; protected $db; @@ -68,7 +68,7 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { return new Result($r, [$changes, $lastId], $this); } - protected function bindValue($value, string $type, int $position): bool { + protected function bindValue($value, int $type, int $position): bool { return $this->st->bindValue($position, $value, is_null($value) ? \SQLITE3_NULL : self::BINDINGS[$type]); } } diff --git a/lib/Db/Statement.php b/lib/Db/Statement.php index b59e075..b85ceca 100644 --- a/lib/Db/Statement.php +++ b/lib/Db/Statement.php @@ -8,24 +8,48 @@ namespace JKingWeb\Arsse\Db; interface Statement { const TYPES = [ - "int" => "integer", - "integer" => "integer", - "float" => "float", - "double" => "float", - "real" => "float", - "numeric" => "float", - "datetime" => "datetime", - "timestamp" => "datetime", - "blob" => "binary", - "bin" => "binary", - "binary" => "binary", - "text" => "string", - "string" => "string", - "str" => "string", - "bool" => "boolean", - "boolean" => "boolean", - "bit" => "boolean", + 'int' => self::T_INTEGER, + 'integer' => self::T_INTEGER, + 'float' => self::T_FLOAT, + 'double' => self::T_FLOAT, + 'real' => self::T_FLOAT, + 'numeric' => self::T_FLOAT, + 'datetime' => self::T_DATETIME, + 'timestamp' => self::T_DATETIME, + 'blob' => self::T_BINARY, + 'bin' => self::T_BINARY, + 'binary' => self::T_BINARY, + 'text' => self::T_STRING, + 'string' => self::T_STRING, + 'str' => self::T_STRING, + 'bool' => self::T_BOOLEAN, + 'boolean' => self::T_BOOLEAN, + 'bit' => self::T_BOOLEAN, + 'strict int' => self::T_NOT_NULL + self::T_INTEGER, + 'strict integer' => self::T_NOT_NULL + self::T_INTEGER, + 'strict float' => self::T_NOT_NULL + self::T_FLOAT, + 'strict double' => self::T_NOT_NULL + self::T_FLOAT, + 'strict real' => self::T_NOT_NULL + self::T_FLOAT, + 'strict numeric' => self::T_NOT_NULL + self::T_FLOAT, + 'strict datetime' => self::T_NOT_NULL + self::T_DATETIME, + 'strict timestamp' => self::T_NOT_NULL + self::T_DATETIME, + 'strict blob' => self::T_NOT_NULL + self::T_BINARY, + 'strict bin' => self::T_NOT_NULL + self::T_BINARY, + 'strict binary' => self::T_NOT_NULL + self::T_BINARY, + 'strict text' => self::T_NOT_NULL + self::T_STRING, + 'strict string' => self::T_NOT_NULL + self::T_STRING, + 'strict str' => self::T_NOT_NULL + self::T_STRING, + 'strict bool' => self::T_NOT_NULL + self::T_BOOLEAN, + 'strict boolean' => self::T_NOT_NULL + self::T_BOOLEAN, + 'strict bit' => self::T_NOT_NULL + self::T_BOOLEAN, ]; + const T_INTEGER = 1; + const T_STRING = 2; + const T_BOOLEAN = 3; + const T_DATETIME = 4; + const T_FLOAT = 5; + const T_BINARY = 6; + const T_NOT_NULL = 100; public function run(...$values): Result; public function runArray(array $values = []): Result; diff --git a/tests/cases/Db/BaseStatement.php b/tests/cases/Db/BaseStatement.php index bd719aa..cdc74a7 100644 --- a/tests/cases/Db/BaseStatement.php +++ b/tests/cases/Db/BaseStatement.php @@ -143,7 +143,7 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest { 'Null as strict integer' => [null, "strict integer", "0"], 'Null as strict float' => [null, "strict float", "0.0"], 'Null as strict string' => [null, "strict string", "''"], - 'Null as strict datetime' => [null, "strict datetime", "'1970-01-01 00:00:00'"], + 'Null as strict datetime' => [null, "strict datetime", "'0001-01-01 00:00:00'"], 'Null as strict boolean' => [null, "strict boolean", "0"], 'True as integer' => [true, "integer", "1"], 'True as float' => [true, "float", "1.0"], @@ -153,7 +153,7 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest { 'True as strict integer' => [true, "strict integer", "1"], 'True as strict float' => [true, "strict float", "1.0"], 'True as strict string' => [true, "strict string", "'1'"], - 'True as strict datetime' => [true, "strict datetime", "'1970-01-01 00:00:00'"], + 'True as strict datetime' => [true, "strict datetime", "'0001-01-01 00:00:00'"], 'True as strict boolean' => [true, "strict boolean", "1"], 'False as integer' => [false, "integer", "0"], 'False as float' => [false, "float", "0.0"], @@ -163,7 +163,7 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest { 'False as strict integer' => [false, "strict integer", "0"], 'False as strict float' => [false, "strict float", "0.0"], 'False as strict string' => [false, "strict string", "''"], - 'False as strict datetime' => [false, "strict datetime", "'1970-01-01 00:00:00'"], + 'False as strict datetime' => [false, "strict datetime", "'0001-01-01 00:00:00'"], 'False as strict boolean' => [false, "strict boolean", "0"], 'Integer as integer' => [2112, "integer", "2112"], 'Integer as float' => [2112, "float", "2112.0"], @@ -213,7 +213,7 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest { 'ASCII string as strict integer' => ["Random string", "strict integer", "0"], 'ASCII string as strict float' => ["Random string", "strict float", "0.0"], 'ASCII string as strict string' => ["Random string", "strict string", "'Random string'"], - 'ASCII string as strict datetime' => ["Random string", "strict datetime", "'1970-01-01 00:00:00'"], + 'ASCII string as strict datetime' => ["Random string", "strict datetime", "'0001-01-01 00:00:00'"], 'ASCII string as strict boolean' => ["Random string", "strict boolean", "1"], 'UTF-8 string as integer' => ["\u{e9}", "integer", "0"], 'UTF-8 string as float' => ["\u{e9}", "float", "0.0"], @@ -223,7 +223,7 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest { 'UTF-8 string as strict integer' => ["\u{e9}", "strict integer", "0"], 'UTF-8 string as strict float' => ["\u{e9}", "strict float", "0.0"], 'UTF-8 string as strict string' => ["\u{e9}", "strict string", "char(233)"], - 'UTF-8 string as strict datetime' => ["\u{e9}", "strict datetime", "'1970-01-01 00:00:00'"], + 'UTF-8 string as strict datetime' => ["\u{e9}", "strict datetime", "'0001-01-01 00:00:00'"], 'UTF-8 string as strict boolean' => ["\u{e9}", "strict boolean", "1"], 'ISO 8601 string as integer' => ["2017-01-09T13:11:17", "integer", "0"], 'ISO 8601 string as float' => ["2017-01-09T13:11:17", "float", "0.0"], @@ -306,7 +306,7 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest { 'Binary string as strict float' => [chr(233).chr(233), "strict float", "0.0"], 'Binary string as strict string' => [chr(233).chr(233), "strict string", "'".chr(233).chr(233)."'"], 'Binary string as strict binary' => [chr(233).chr(233), "strict binary", "x'e9e9'"], - 'Binary string as strict datetime' => [chr(233).chr(233), "strict datetime", "'1970-01-01 00:00:00'"], + 'Binary string as strict datetime' => [chr(233).chr(233), "strict datetime", "'0001-01-01 00:00:00'"], 'Binary string as strict boolean' => [chr(233).chr(233), "strict boolean", "1"], 'ISO 8601 string as binary' => ["2017-01-09T13:11:17", "binary", "x'323031372d30312d30395431333a31313a3137'"], 'ISO 8601 string as strict binary' => ["2017-01-09T13:11:17", "strict binary", "x'323031372d30312d30395431333a31313a3137'"], From 21fdd66d3796f0df42941921441c212a55eec00e Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 1 Mar 2019 22:36:25 -0500 Subject: [PATCH 028/142] Work around limit to SQL parameter placeholders for IN() clauses Improves #150 LIKE-based matches also need to be similarly conservative --- lib/Database.php | 300 +++++++++++-------------- lib/Db/Driver.php | 6 + lib/Db/MySQL/Driver.php | 4 + lib/Db/PDODriver.php | 4 + lib/Db/PostgreSQL/Driver.php | 4 + lib/Db/SQLite3/Driver.php | 4 + tests/cases/Database/SeriesArticle.php | 7 +- tests/cases/Db/BaseDriver.php | 4 + 8 files changed, 155 insertions(+), 178 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 61eefa5..f0987c3 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -38,8 +38,10 @@ use JKingWeb\Arsse\Misc\ValueInfo; class Database { /** The version number of the latest schema the interface is aware of */ const SCHEMA_VERSION = 4; - /** The maximum number of articles to mark in one query without chunking */ - const LIMIT_ARTICLES = 50; + /** The size of a set of values beyond which the set will be embedded into the query text */ + const LIMIT_SET_SIZE = 25; + /** The length of a string in an embedded set beyond which a parameter placeholder will be used for the string */ + const LIMIT_SET_STRING_LENGTH = 200; /** A map database driver short-names and their associated class names */ const DRIVER_NAMES = [ 'sqlite3' => \JKingWeb\Arsse\Db\SQLite3\Driver::class, @@ -126,29 +128,50 @@ class Database { return $out; } - /** Conputes the contents of an SQL "IN()" clause, producing one parameter placeholder for each input value + /** Computes the contents of an SQL "IN()" clause, for each input value either embedding the value or producing a parameter placeholder * - * Returns an indexed array containing the clause text, an array of types, and the array of values + * Returns an indexed array containing the clause text, an array of types, and an array of values. Note that the array of output values may not match the array of input values * * @param array $values Arbitrary values * @param string $type A single data type applied to each value */ protected function generateIn(array $values, string $type): array { - $out = [ - "", // query clause - [], // binding types - $values, // binding values - ]; - if (sizeof($values)) { - // the query clause is just a series of question marks separated by commas - $out[0] = implode(",", array_fill(0, sizeof($values), "?")); - // the binding types are just a repetition of the supplied type - $out[1] = array_fill(0, sizeof($values), $type); - } else { + if (!sizeof($values)) { // if the set is empty, some databases require an explicit null - $out[0] = "null"; + return ["null", [], []]; + } + $t = (Statement::TYPES[$type] ?? 0) % Statement::T_NOT_NULL; + if (sizeof($values) > self::LIMIT_SET_SIZE && ($t == Statement::T_INTEGER || $t == Statement::T_STRING)) { + $clause = []; + $params = []; + $count = 0; + $convType = Db\AbstractStatement::TYPE_NORM_MAP[Statement::TYPES[$type]]; + foreach($values as $v) { + $v = ValueInfo::normalize($v, $convType, null, "sql"); + if (is_null($v)) { + // nulls are pointless to have + continue; + } elseif (is_string($v)) { + if (strlen($v) > self::LIMIT_SET_STRING_LENGTH) { + $clause[] = "?"; + $params[] = $v; + } else { + $clause[] = $this->db->literalString($v); + } + } else { + $clause[] = ValueInfo::normalize($v, ValueInfo::T_STRING, null, "sql"); + } + $count++; + } + if (!$count) { + // the set is actually empty + return ["null", [], []]; + } else { + return [implode(",", $clause), array_fill(0, sizeof($params), $type), $params]; + } + } else { + return [implode(",", array_fill(0, sizeof($values), "?")), array_fill(0, sizeof($values), $type), $values]; } - return $out; } /** Computes basic LIKE-based text search constraints for use in a WHERE clause @@ -1074,10 +1097,10 @@ class Database { */ public function feedMatchIds(int $feedID, array $ids = [], array $hashesUT = [], array $hashesUC = [], array $hashesTC = []): Db\Result { // compile SQL IN() clauses and necessary type bindings for the four identifier lists - list($cId, $tId) = $this->generateIn($ids, "str"); - list($cHashUT, $tHashUT) = $this->generateIn($hashesUT, "str"); - list($cHashUC, $tHashUC) = $this->generateIn($hashesUC, "str"); - list($cHashTC, $tHashTC) = $this->generateIn($hashesTC, "str"); + list($cId, $tId, $vId) = $this->generateIn($ids, "str"); + list($cHashUT, $tHashUT, $vHashUT) = $this->generateIn($hashesUT, "str"); + list($cHashUC, $tHashUC, $vHashUC) = $this->generateIn($hashesUC, "str"); + list($cHashTC, $tHashTC, $vHashTC) = $this->generateIn($hashesTC, "str"); // perform the query return $articles = $this->db->prepare( "SELECT id, edited, guid, url_title_hash, url_content_hash, title_content_hash FROM arsse_articles WHERE feed = ? and (guid in($cId) or url_title_hash in($cHashUT) or url_content_hash in($cHashUC) or title_content_hash in($cHashTC))", @@ -1086,7 +1109,7 @@ class Database { $tHashUT, $tHashUC, $tHashTC - )->run($feedID, $ids, $hashesUT, $hashesUC, $hashesTC); + )->run($feedID, $vId, $vHashUT, $vHashUC, $vHashTC); } /** Computes an SQL query to find and retrieve data about articles in the database @@ -1180,25 +1203,25 @@ class Database { $q->setLimit($context->limit, $context->offset); // handle the simple context options $options = [ - // each context array consists of a column identifier (see $colDefs above), a comparison operator, a data type, an option to pair with for BETWEEN evaluation, and an upper bound if the value is an array - "edition" => ["edition", "=", "int", "", 1], - "editions" => ["edition", "in", "int", "", self::LIMIT_ARTICLES], - "article" => ["id", "=", "int", "", 1], - "articles" => ["id", "in", "int", "", self::LIMIT_ARTICLES], - "oldestArticle" => ["id", ">=", "int", "latestArticle", 1], - "latestArticle" => ["id", "<=", "int", "oldestArticle", 1], - "oldestEdition" => ["edition", ">=", "int", "latestEdition", 1], - "latestEdition" => ["edition", "<=", "int", "oldestEdition", 1], - "modifiedSince" => ["modified_date", ">=", "datetime", "notModifiedSince", 1], - "notModifiedSince" => ["modified_date", "<=", "datetime", "modifiedSince", 1], - "markedSince" => ["marked_date", ">=", "datetime", "notMarkedSince", 1], - "notMarkedSince" => ["marked_date", "<=", "datetime", "markedSince", 1], - "folderShallow" => ["folder", "=", "int", "", 1], - "subscription" => ["subscription", "=", "int", "", 1], - "unread" => ["unread", "=", "bool", "", 1], - "starred" => ["starred", "=", "bool", "", 1], + // each context array consists of a column identifier (see $colDefs above), a comparison operator, a data type, and an option to pair with for BETWEEN evaluation + "edition" => ["edition", "=", "int", ""], + "editions" => ["edition", "in", "int", ""], + "article" => ["id", "=", "int", ""], + "articles" => ["id", "in", "int", ""], + "oldestArticle" => ["id", ">=", "int", "latestArticle"], + "latestArticle" => ["id", "<=", "int", "oldestArticle"], + "oldestEdition" => ["edition", ">=", "int", "latestEdition"], + "latestEdition" => ["edition", "<=", "int", "oldestEdition"], + "modifiedSince" => ["modified_date", ">=", "datetime", "notModifiedSince"], + "notModifiedSince" => ["modified_date", "<=", "datetime", "modifiedSince"], + "markedSince" => ["marked_date", ">=", "datetime", "notMarkedSince"], + "notMarkedSince" => ["marked_date", "<=", "datetime", "markedSince"], + "folderShallow" => ["folder", "=", "int", ""], + "subscription" => ["subscription", "=", "int", ""], + "unread" => ["unread", "=", "bool", ""], + "starred" => ["starred", "=", "bool", ""], ]; - foreach ($options as $m => list($col, $op, $type, $pair, $max)) { + foreach ($options as $m => list($col, $op, $type, $pair)) { if (!$context->$m()) { // context is not being used continue; @@ -1206,8 +1229,6 @@ class Database { // context option is an array of values if (!$context->$m) { throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element - } elseif (sizeof($context->$m) > $max) { - throw new Db\ExceptionInput("tooLong", ['field' => $m, 'action' => $this->caller(), 'max' => $max]); // @codeCoverageIgnore } list($clause, $types, $values) = $this->generateIn($context->$m, $type); $q->setWhere("{$colDefs[$col]} $op ($clause)", $types, $values); @@ -1224,7 +1245,7 @@ class Database { } } // further handle exclusionary options if specified - foreach ($options as $m => list($col, $op, $type, $pair, $max)) { + foreach ($options as $m => list($col, $op, $type, $pair)) { if (!method_exists($context->not, $m) || !$context->not->$m()) { // context option is not being used continue; @@ -1232,8 +1253,6 @@ class Database { if (!$context->not->$m) { // for exclusions we don't care if the array is empty continue; - } elseif (sizeof($context->not->$m) > $max) { - throw new Db\ExceptionInput("tooLong", ['field' => "$m (not)", 'action' => $this->caller(), 'max' => $max]); } list($clause, $types, $values) = $this->generateIn($context->not->$m, $type); $q->setWhereNot("{$colDefs[$col]} $op ($clause)", $types, $values); @@ -1315,35 +1334,10 @@ class Database { $q->setWhereNot(...$this->generateSearch($context->not->$m, $cols, true)); } // return the query + //var_export((string) $q); return $q; } - /** Chunk a context with more than the maximum number of articles or editions into an array of contexts */ - protected function contextChunk(Context $context): array { - $exception = ""; - if ($context->editions()) { - // editions take precedence over articles - if (sizeof($context->editions) > self::LIMIT_ARTICLES) { - $exception = "editions"; - } - } elseif ($context->articles()) { - if (sizeof($context->articles) > self::LIMIT_ARTICLES) { - $exception = "articles"; - } - } - if ($exception) { - $out = []; - $list = array_chunk($context->$exception, self::LIMIT_ARTICLES); - foreach ($list as $chunk) { - $out[] = (clone $context)->$exception($chunk); - } - return $out; - } else { - return []; - } - } - - /** Lists articles in the database which match a given query context * * If an empty column list is supplied, a count of articles is returned instead @@ -1357,22 +1351,11 @@ class Database { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } $context = $context ?? new Context; - // if the context has more articles or editions than we can process in one query, perform a series of queries and return an aggregate result - if ($contexts = $this->contextChunk($context)) { - $out = []; - $tr = $this->begin(); - foreach ($contexts as $context) { - $out[] = $this->articleList($user, $context, $fields); - } - $tr->commit(); - return new Db\ResultAggregate(...$out); - } else { - $q = $this->articleQuery($user, $context, $fields); - $q->setOrder("arsse_articles.edited".($context->reverse ? " desc" : "")); - $q->setOrder("latest_editions.edition".($context->reverse ? " desc" : "")); - // perform the query and return results - return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); - } + $q = $this->articleQuery($user, $context, $fields); + $q->setOrder("arsse_articles.edited".($context->reverse ? " desc" : "")); + $q->setOrder("latest_editions.edition".($context->reverse ? " desc" : "")); + // perform the query and return results + return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); } /** Returns a count of articles which match the given query context @@ -1385,19 +1368,8 @@ class Database { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } $context = $context ?? new Context; - // if the context has more articles or editions than we can process in one query, perform a series of queries and return an aggregate result - if ($contexts = $this->contextChunk($context)) { - $out = 0; - $tr = $this->begin(); - foreach ($contexts as $context) { - $out += $this->articleCount($user, $context); - } - $tr->commit(); - return $out; - } else { - $q = $this->articleQuery($user, $context, []); - return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); - } + $q = $this->articleQuery($user, $context, []); + return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); } /** Applies one or multiple modifications to all articles matching the given query context @@ -1425,80 +1397,69 @@ class Database { return 0; } $context = $context ?? new Context; - // if the context has more articles or editions than we can process in one query, perform a series of queries and return an aggregate result - if ($contexts = $this->contextChunk($context)) { - $out = 0; - $tr = $this->begin(); - foreach ($contexts as $context) { - $out += $this->articleMark($user, $data, $context); - } - $tr->commit(); - return $out; - } else { - $tr = $this->begin(); - $out = 0; - if ($data['read'] || $data['starred'] || strlen($data['note'] ?? "")) { - // first prepare a query to insert any missing marks rows for the articles we want to mark - // but only insert new mark records if we're setting at least one "positive" mark - $q = $this->articleQuery($user, $context, ["id", "subscription", "note"]); - $q->setWhere("arsse_marks.starred is null"); // null means there is no marks row for the article - $this->db->prepare("INSERT INTO arsse_marks(article,subscription,note) ".$q->getQuery(), $q->getTypes())->run($q->getValues()); + $tr = $this->begin(); + $out = 0; + if ($data['read'] || $data['starred'] || strlen($data['note'] ?? "")) { + // first prepare a query to insert any missing marks rows for the articles we want to mark + // but only insert new mark records if we're setting at least one "positive" mark + $q = $this->articleQuery($user, $context, ["id", "subscription", "note"]); + $q->setWhere("arsse_marks.starred is null"); // null means there is no marks row for the article + $this->db->prepare("INSERT INTO arsse_marks(article,subscription,note) ".$q->getQuery(), $q->getTypes())->run($q->getValues()); + } + if (isset($data['read']) && (isset($data['starred']) || isset($data['note'])) && ($context->edition() || $context->editions())) { + // if marking by edition both read and something else, do separate marks for starred and note than for read + // marking as read is ignored if the edition is not the latest, but the same is not true of the other two marks + $this->db->query("UPDATE arsse_marks set touched = 0 where touched <> 0"); + // set read marks + $q = $this->articleQuery($user, $context, ["id", "subscription"]); + $q->setWhere("arsse_marks.read <> coalesce(?,arsse_marks.read)", "bool", $data['read']); + $q->pushCTE("target_articles(article,subscription)"); + $q->setBody("UPDATE arsse_marks set \"read\" = ?, touched = 1 where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", "bool", $data['read']); + $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); + // get the articles associated with the requested editions + if ($context->edition()) { + $context->article($this->articleValidateEdition($user, $context->edition)['article'])->edition(null); + } else { + $context->articles($this->editionArticle(...$context->editions))->editions(null); } - if (isset($data['read']) && (isset($data['starred']) || isset($data['note'])) && ($context->edition() || $context->editions())) { - // if marking by edition both read and something else, do separate marks for starred and note than for read - // marking as read is ignored if the edition is not the latest, but the same is not true of the other two marks - $this->db->query("UPDATE arsse_marks set touched = 0 where touched <> 0"); - // set read marks + // set starred and/or note marks (unless all requested editions actually do not exist) + if ($context->article || $context->articles) { $q = $this->articleQuery($user, $context, ["id", "subscription"]); - $q->setWhere("arsse_marks.read <> coalesce(?,arsse_marks.read)", "bool", $data['read']); + $q->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred))", ["str", "bool"], [$data['note'], $data['starred']]); $q->pushCTE("target_articles(article,subscription)"); - $q->setBody("UPDATE arsse_marks set \"read\" = ?, touched = 1 where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", "bool", $data['read']); + $data = array_filter($data, function($v) { + return isset($v); + }); + list($set, $setTypes, $setValues) = $this->generateSet($data, ['starred' => "bool", 'note' => "str"]); + $q->setBody("UPDATE arsse_marks set touched = 1, $set where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", $setTypes, $setValues); $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); + } + // finally set the modification date for all touched marks and return the number of affected marks + $out = $this->db->query("UPDATE arsse_marks set modified = CURRENT_TIMESTAMP, touched = 0 where touched = 1")->changes(); + } else { + if (!isset($data['read']) && ($context->edition() || $context->editions())) { // get the articles associated with the requested editions if ($context->edition()) { $context->article($this->articleValidateEdition($user, $context->edition)['article'])->edition(null); } else { $context->articles($this->editionArticle(...$context->editions))->editions(null); } - // set starred and/or note marks (unless all requested editions actually do not exist) - if ($context->article || $context->articles) { - $q = $this->articleQuery($user, $context, ["id", "subscription"]); - $q->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred))", ["str", "bool"], [$data['note'], $data['starred']]); - $q->pushCTE("target_articles(article,subscription)"); - $data = array_filter($data, function($v) { - return isset($v); - }); - list($set, $setTypes, $setValues) = $this->generateSet($data, ['starred' => "bool", 'note' => "str"]); - $q->setBody("UPDATE arsse_marks set touched = 1, $set where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", $setTypes, $setValues); - $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); - } - // finally set the modification date for all touched marks and return the number of affected marks - $out = $this->db->query("UPDATE arsse_marks set modified = CURRENT_TIMESTAMP, touched = 0 where touched = 1")->changes(); - } else { - if (!isset($data['read']) && ($context->edition() || $context->editions())) { - // get the articles associated with the requested editions - if ($context->edition()) { - $context->article($this->articleValidateEdition($user, $context->edition)['article'])->edition(null); - } else { - $context->articles($this->editionArticle(...$context->editions))->editions(null); - } - if (!$context->article && !$context->articles) { - return 0; - } + if (!$context->article && !$context->articles) { + return 0; } - $q = $this->articleQuery($user, $context, ["id", "subscription"]); - $q->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred) or arsse_marks.read <> coalesce(?,arsse_marks.read))", ["str", "bool", "bool"], [$data['note'], $data['starred'], $data['read']]); - $q->pushCTE("target_articles(article,subscription)"); - $data = array_filter($data, function($v) { - return isset($v); - }); - list($set, $setTypes, $setValues) = $this->generateSet($data, ['read' => "bool", 'starred' => "bool", 'note' => "str"]); - $q->setBody("UPDATE arsse_marks set $set, modified = CURRENT_TIMESTAMP where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", $setTypes, $setValues); - $out = $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes(); } - $tr->commit(); - return $out; + $q = $this->articleQuery($user, $context, ["id", "subscription"]); + $q->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred) or arsse_marks.read <> coalesce(?,arsse_marks.read))", ["str", "bool", "bool"], [$data['note'], $data['starred'], $data['read']]); + $q->pushCTE("target_articles(article,subscription)"); + $data = array_filter($data, function($v) { + return isset($v); + }); + list($set, $setTypes, $setValues) = $this->generateSet($data, ['read' => "bool", 'starred' => "bool", 'note' => "str"]); + $q->setBody("UPDATE arsse_marks set $set, modified = CURRENT_TIMESTAMP where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", $setTypes, $setValues); + $out = $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes(); } + $tr->commit(); + return $out; } /** Returns statistics about the articles starred by the given user @@ -1685,20 +1646,9 @@ class Database { public function editionArticle(int ...$edition): array { $out = []; $context = (new Context)->editions($edition); - // if the context has more articles or editions than we can process in one query, perform a series of queries and return an aggregate result - if ($contexts = $this->contextChunk($context)) { - $articles = $editions = []; - foreach ($contexts as $context) { - $out = $this->editionArticle(...$context->editions); - $editions = array_merge($editions, array_map("intval", array_keys($out))); - $articles = array_merge($articles, array_map("intval", array_values($out))); - } - return array_combine($editions, $articles); - } else { - list($in, $inTypes) = $this->generateIn($context->editions, "int"); - $out = $this->db->prepare("SELECT id as edition, article from arsse_editions where id in($in)", $inTypes)->run($context->editions)->getAll(); - return $out ? array_combine(array_column($out, "edition"), array_column($out, "article")) : []; - } + list($in, $inTypes, $inValues) = $this->generateIn($context->editions, "int"); + $out = $this->db->prepare("SELECT id as edition, article from arsse_editions where id in($in)", $inTypes)->run($inValues)->getAll(); + return $out ? array_combine(array_column($out, "edition"), array_column($out, "article")) : []; } /** Creates a label, and returns its numeric identifier diff --git a/lib/Db/Driver.php b/lib/Db/Driver.php index 959a550..b0f572c 100644 --- a/lib/Db/Driver.php +++ b/lib/Db/Driver.php @@ -76,4 +76,10 @@ interface Driver { * - "like": the case-insensitive LIKE operator */ public function sqlToken(string $token): string; + + /** Returns a string literal which is properly escaped to guard against SQL injections. Delimiters are included in the output string + * + * This functionality should be avoided in favour of using statement parameters whenever possible + */ + public function literalString(string $str): string; } diff --git a/lib/Db/MySQL/Driver.php b/lib/Db/MySQL/Driver.php index 8a4fe44..edd5f77 100644 --- a/lib/Db/MySQL/Driver.php +++ b/lib/Db/MySQL/Driver.php @@ -212,4 +212,8 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement { return new Statement($this->db, $query, $paramTypes, $this->packetSize); } + + public function literalString(string $str): string { + return "'".$this->db->real_escape_string($str)."'"; + } } diff --git a/lib/Db/PDODriver.php b/lib/Db/PDODriver.php index c5b4f4d..df5dcc3 100644 --- a/lib/Db/PDODriver.php +++ b/lib/Db/PDODriver.php @@ -28,4 +28,8 @@ trait PDODriver { } return new PDOResult($this->db, $r); } + + public function literalString(string $str): string { + return $this->db->quote($str); + } } diff --git a/lib/Db/PostgreSQL/Driver.php b/lib/Db/PostgreSQL/Driver.php index 08c439d..12ad8fc 100644 --- a/lib/Db/PostgreSQL/Driver.php +++ b/lib/Db/PostgreSQL/Driver.php @@ -221,4 +221,8 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement { return new Statement($this->db, $query, $paramTypes); } + + public function literalString(string $str): string { + return pg_escape_literal($this->db, $str); + } } diff --git a/lib/Db/SQLite3/Driver.php b/lib/Db/SQLite3/Driver.php index f7e47fb..3682d03 100644 --- a/lib/Db/SQLite3/Driver.php +++ b/lib/Db/SQLite3/Driver.php @@ -179,4 +179,8 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { $this->exec((!$rollback) ? "COMMIT" : "ROLLBACK"); return true; } + + public function literalString(string $str): string { + return "'".\SQLite3::escapeString($str)."'"; + } } diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 3887d78..e2aa598 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -432,7 +432,7 @@ trait SeriesArticle { "Multiple unstarred articles" => [(new Context)->articles([1,2,3])->starred(false), [2,3]], "Multiple articles" => [(new Context)->articles([1,20,50]), [1,20]], "Multiple editions" => [(new Context)->editions([1,1001,50]), [1,20]], - "150 articles" => [(new Context)->articles(range(1, Database::LIMIT_ARTICLES * 3)), [1,2,3,4,5,6,7,8,19,20]], + "150 articles" => [(new Context)->articles(range(1, Database::LIMIT_SET_SIZE * 3)), [1,2,3,4,5,6,7,8,19,20]], "Search title or content 1" => [(new Context)->searchTerms(["Article"]), [1,2,3]], "Search title or content 2" => [(new Context)->searchTerms(["one", "first"]), [1]], "Search title or content 3" => [(new Context)->searchTerms(["one first"]), []], @@ -455,6 +455,7 @@ trait SeriesArticle { "Search with exclusion" => [(new Context)->searchTerms(["Article"])->not->searchTerms(["one", "two"]), [3]], "Excluded folder tree" => [(new Context)->not->folder(1), [1,2,3,4,19,20]], "Excluding label ID 2" => [(new Context)->not->label(2), [2,3,4,6,7,8,19]], + "Excluding label 'Fascinating'" => [(new Context)->not->labelName("Fascinating"), [2,3,4,6,7,8,19]], ]; } @@ -744,7 +745,7 @@ trait SeriesArticle { } public function testMarkTooManyMultipleArticles() { - $this->assertSame(7, Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->articles(range(1, Database::LIMIT_ARTICLES * 3)))); + $this->assertSame(7, Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->articles(range(1, Database::LIMIT_SET_SIZE * 3)))); } public function testMarkAMissingArticle() { @@ -907,7 +908,7 @@ trait SeriesArticle { $this->assertSame(2, Arsse::$db->articleCount("john.doe@example.com", (new Context)->starred(true))); $this->assertSame(4, Arsse::$db->articleCount("john.doe@example.com", (new Context)->folder(1))); $this->assertSame(0, Arsse::$db->articleCount("jane.doe@example.com", (new Context)->starred(true))); - $this->assertSame(10, Arsse::$db->articleCount("john.doe@example.com", (new Context)->articles(range(1, Database::LIMIT_ARTICLES *3)))); + $this->assertSame(10, Arsse::$db->articleCount("john.doe@example.com", (new Context)->articles(range(1, Database::LIMIT_SET_SIZE * 3)))); } public function testCountArticlesWithoutAuthority() { diff --git a/tests/cases/Db/BaseDriver.php b/tests/cases/Db/BaseDriver.php index 4967e84..74ef7c9 100644 --- a/tests/cases/Db/BaseDriver.php +++ b/tests/cases/Db/BaseDriver.php @@ -378,4 +378,8 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest { $this->drv->savepointUndo(); $this->assertTrue($this->exec(str_replace("#", "3", $this->setVersion))); } + + public function testProduceAStringLiteral() { + $this->assertSame("'It''s a string!'", $this->drv->literalString("It's a string!")); + } } From 44366f48bf1a6cf96a25ba7fae5e653bbe0cd079 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 2 Mar 2019 13:53:43 -0500 Subject: [PATCH 029/142] Remove arbitrary search term limits; fixes #150 --- README.md | 1 - lib/AbstractException.php | 1 + lib/Database.php | 39 +++++++++++---------- locale/en.php | 2 ++ tests/cases/Database/SeriesArticle.php | 11 +----- tests/cases/REST/TinyTinyRSS/TestSearch.php | 1 - 6 files changed, 25 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index d4fca7f..ab7dc2e 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,6 @@ We are not aware of any other extensions to the TTRSS protocol. If you know of a - Providing the `setArticleLabel` operation with an invalid label normally silently fails; The Arsse returns an `INVALID_USAGE` error instead - Processing of the `search` parameter of the `getHeadlines` operation differs in the following ways: - Values other than `"true"` or `"false"` for the `unread`, `star`, and `pub` special keywords treat the entire token as a search term rather than as `"false"` - - Limits are placed on the number of search terms: ten each for `title`, `author`, and `note`, and twenty for content searching; exceeding the limits will yield a non-standard `TOO_MANY_SEARCH_TERMS` error - Invalid dates are ignored rather than assumed to be `"1970-01-01"` - Only a single negative date is allowed (this is a known bug rather than intentional) - Dates are always relative to UTC diff --git a/lib/AbstractException.php b/lib/AbstractException.php index 0249678..7df2263 100644 --- a/lib/AbstractException.php +++ b/lib/AbstractException.php @@ -11,6 +11,7 @@ abstract class AbstractException extends \Exception { "Exception.uncoded" => -1, "Exception.unknown" => 10000, "Exception.constantUnknown" => 10001, + "Exception.arrayEmpty" => 10002, "ExceptionType.strictFailure" => 10011, "ExceptionType.typeUnknown" => 10012, "Lang/Exception.defaultFileMissing" => 10101, diff --git a/lib/Database.php b/lib/Database.php index f0987c3..7c47ad3 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -185,23 +185,33 @@ class Database { * @param boolean $matchAny Whether the search is successful when it matches any (true) or all (false) terms */ protected function generateSearch(array $terms, array $cols, bool $matchAny = false): array { + if (!$cols) { + throw new Exception("arrayEmpty", "cols"); // @codeCoverageIgnore + } $clause = []; $types = []; $values = []; $like = $this->db->sqlToken("like"); + $embedSet = sizeof($terms) > ((int) (self::LIMIT_SET_SIZE / sizeof($cols))); foreach($terms as $term) { + $embedTerm = ($embedSet && strlen($term) <= self::LIMIT_SET_STRING_LENGTH); $term = str_replace(["%", "_", "^"], ["^%", "^_", "^^"], $term); $term = "%$term%"; + $term = $embedTerm ? $this->db->literalString($term) : $term; $spec = []; foreach ($cols as $col) { - $spec[] = "$col $like ? escape '^'"; - $types[] = "str"; - $values[] = $term; + if ($embedTerm) { + $spec[] = "$col $like $term escape '^'"; + } else { + $spec[] = "$col $like ? escape '^'"; + $types[] = "str"; + $values[] = $term; + } } $clause[] = "(".implode(" or ", $spec).")"; } $glue = $matchAny ? "or" : "and"; - $clause = "(".implode(" $glue ", $clause).")"; + $clause = $clause ? "(".implode(" $glue ", $clause).")" : ""; return [$clause, $types, $values]; } @@ -1307,34 +1317,27 @@ class Database { } // handle text-matching context options $options = [ - "titleTerms" => [10, ["arsse_articles.title"]], - "searchTerms" => [20, ["arsse_articles.title", "arsse_articles.content"]], - "authorTerms" => [10, ["arsse_articles.author"]], - "annotationTerms" => [20, ["arsse_marks.note"]], + "titleTerms" => ["arsse_articles.title"], + "searchTerms" => ["arsse_articles.title", "arsse_articles.content"], + "authorTerms" => ["arsse_articles.author"], + "annotationTerms" => ["arsse_marks.note"], ]; - foreach ($options as $m => list($max, $cols)) { + foreach ($options as $m => $cols) { if (!$context->$m()) { continue; } elseif (!$context->$m) { throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element - } elseif (sizeof($context->$m) > $max) { - throw new Db\ExceptionInput("tooLong", ['field' => $m, 'action' => $this->caller(), 'max' => $max]); } $q->setWhere(...$this->generateSearch($context->$m, $cols)); } // further handle exclusionary text-matching context options - foreach ($options as $m => list($max, $cols)) { - if (!$context->not->$m()) { - continue; - } elseif (!$context->not->$m) { + foreach ($options as $m => $cols) { + if (!$context->not->$m() || !$context->not->$m) { continue; - } elseif (sizeof($context->not->$m) > $max) { - throw new Db\ExceptionInput("tooLong", ['field' => "$m (not)", 'action' => $this->caller(), 'max' => $max]); } $q->setWhereNot(...$this->generateSearch($context->not->$m, $cols, true)); } // return the query - //var_export((string) $q); return $q; } diff --git a/locale/en.php b/locale/en.php index f576442..5e8ad0f 100644 --- a/locale/en.php +++ b/locale/en.php @@ -36,6 +36,8 @@ return [ 'Exception.JKingWeb/Arsse/Exception.unknown' => 'An unknown error has occurred', // indicates programming error 'Exception.JKingWeb/Arsse/Exception.constantUnknown' => 'Supplied constant value ({0}) is unknown or invalid in the context in which it was used', + // indicates programming error + 'Exception.JKingWeb/Arsse/Exception.arrayEmpty' => 'Supplied array "{0}" is empty, but should have at least one element', 'Exception.JKingWeb/Arsse/ExceptionType.strictFailure' => 'Supplied value could not be normalized to {0, select, 1 {null} 2 {boolean} diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index e2aa598..f652c6f 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -456,6 +456,7 @@ trait SeriesArticle { "Excluded folder tree" => [(new Context)->not->folder(1), [1,2,3,4,19,20]], "Excluding label ID 2" => [(new Context)->not->label(2), [2,3,4,6,7,8,19]], "Excluding label 'Fascinating'" => [(new Context)->not->labelName("Fascinating"), [2,3,4,6,7,8,19]], + "Search 501 terms" => [(new Context)->searchTerms(array_merge(range(1,500),[str_repeat("a", 1000)])), []], ]; } @@ -991,18 +992,8 @@ trait SeriesArticle { Arsse::$db->articleList($this->user, (new Context)->searchTerms([])); } - public function testSearchTooManyTerms() { - $this->assertException("tooLong", "Db", "ExceptionInput"); - Arsse::$db->articleList($this->user, (new Context)->searchTerms(range(1, 105))); - } - public function testSearchTooFewTermsInNote() { $this->assertException("tooShort", "Db", "ExceptionInput"); Arsse::$db->articleList($this->user, (new Context)->annotationTerms([])); } - - public function testSearchTooManyTermsInNote() { - $this->assertException("tooLong", "Db", "ExceptionInput"); - Arsse::$db->articleList($this->user, (new Context)->annotationTerms(range(1, 105))); - } } diff --git a/tests/cases/REST/TinyTinyRSS/TestSearch.php b/tests/cases/REST/TinyTinyRSS/TestSearch.php index 62ad553..c858d1b 100644 --- a/tests/cases/REST/TinyTinyRSS/TestSearch.php +++ b/tests/cases/REST/TinyTinyRSS/TestSearch.php @@ -120,7 +120,6 @@ class TestSearch extends \JKingWeb\Arsse\Test\AbstractTest { /** @dataProvider provideSearchStrings */ public function testApplySearchToContext(string $search, $exp) { $act = Search::parse($search); - //var_export($act); $this->assertEquals($exp, $act); } } From 5efef2c2d0a4a27ba388a38b98f04cbfd0d13fc3 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 2 Mar 2019 14:59:44 -0500 Subject: [PATCH 030/142] Console command to refresh all feeds once; fixes #147 --- CHANGELOG | 6 +++++- lib/CLI.php | 11 ++++++++--- tests/cases/CLI/TestCLI.php | 10 ++++++++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index af5159f..0bd9c39 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,8 +1,12 @@ -Version 0.7.0 (2019-??-??) +Version 0.7.0 (2019-03-02) ========================== New features: - Support for basic freeform searching in Tiny Tiny RSS +- Console command to refresh all stale feeds once then exit + +Bug fixes: +- Ensure updating does not fail with newsfeeds larger than 250 entries Version 0.6.1 (2019-01-23) ========================== diff --git a/lib/CLI.php b/lib/CLI.php index 8ff1e1d..efb1f99 100644 --- a/lib/CLI.php +++ b/lib/CLI.php @@ -12,6 +12,7 @@ class CLI { const USAGE = << arsse.php conf save-defaults [] arsse.php user [list] @@ -23,8 +24,8 @@ Usage: arsse.php --help | -h The Arsse command-line interface currently allows you to start the refresh -daemon, refresh a specific feed by numeric ID, manage users, or save default -configuration to a sample file. +daemon, refresh all feeds or a specific feed by numeric ID, manage users, +or save default configuration to a sample file. USAGE_TEXT; protected function usage($prog): string { @@ -58,7 +59,7 @@ USAGE_TEXT; 'help' => false, ]); try { - switch ($this->command(["--help", "--version", "daemon", "feed refresh", "conf save-defaults", "user"], $args)) { + switch ($this->command(["--help", "--version", "daemon", "feed refresh", "feed refresh-all", "conf save-defaults", "user"], $args)) { case "--help": echo $this->usage($argv0).\PHP_EOL; return 0; @@ -72,6 +73,10 @@ USAGE_TEXT; case "feed refresh": $this->loadConf(); return (int) !Arsse::$db->feedUpdate((int) $args[''], true); + case "feed refresh-all": + $this->loadConf(); + $this->getService()->watch(false); + return 0; case "conf save-defaults": $file = $args['']; $file = ($file === "-" ? null : $file) ?? "php://output"; diff --git a/tests/cases/CLI/TestCLI.php b/tests/cases/CLI/TestCLI.php index c2a6b52..608eebc 100644 --- a/tests/cases/CLI/TestCLI.php +++ b/tests/cases/CLI/TestCLI.php @@ -74,6 +74,16 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { Phake::verify($this->cli)->getService; } + public function testRefreshAllFeeds() { + $srv = Phake::mock(Service::class); + Phake::when($srv)->watch->thenReturn(new \DateTimeImmutable); + Phake::when($this->cli)->getService->thenReturn($srv); + $this->assertConsole($this->cli, "arsse.php feed refresh-all", 0); + $this->assertLoaded(true); + Phake::verify($srv)->watch(false); + Phake::verify($this->cli)->getService; + } + /** @dataProvider provideFeedUpdates */ public function testRefreshAFeed(string $cmd, int $exitStatus, string $output) { Arsse::$db = Phake::mock(Database::class); From fb1bdbfb372869c19ca58f9ab142652df402cc9f Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sun, 3 Mar 2019 12:10:18 -0500 Subject: [PATCH 031/142] Database schema for subscription tags --- lib/Database.php | 2 +- sql/MySQL/0.sql | 10 +++++----- sql/MySQL/1.sql | 4 ++-- sql/MySQL/2.sql | 2 +- sql/MySQL/3.sql | 2 +- sql/MySQL/4.sql | 23 +++++++++++++++++++++++ sql/PostgreSQL/0.sql | 2 +- sql/PostgreSQL/1.sql | 2 +- sql/PostgreSQL/2.sql | 2 +- sql/PostgreSQL/3.sql | 2 +- sql/PostgreSQL/4.sql | 23 +++++++++++++++++++++++ sql/SQLite3/0.sql | 2 +- sql/SQLite3/1.sql | 4 ++-- sql/SQLite3/2.sql | 2 +- sql/SQLite3/3.sql | 2 +- sql/SQLite3/4.sql | 25 +++++++++++++++++++++++++ 16 files changed, 90 insertions(+), 19 deletions(-) create mode 100644 sql/MySQL/4.sql create mode 100644 sql/PostgreSQL/4.sql create mode 100644 sql/SQLite3/4.sql diff --git a/lib/Database.php b/lib/Database.php index 7c47ad3..cecfb33 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -37,7 +37,7 @@ use JKingWeb\Arsse\Misc\ValueInfo; */ class Database { /** The version number of the latest schema the interface is aware of */ - const SCHEMA_VERSION = 4; + const SCHEMA_VERSION = 5; /** The size of a set of values beyond which the set will be embedded into the query text */ const LIMIT_SET_SIZE = 25; /** The length of a string in an embedded set beyond which a parameter placeholder will be used for the string */ diff --git a/sql/MySQL/0.sql b/sql/MySQL/0.sql index f6b00e2..3901ad7 100644 --- a/sql/MySQL/0.sql +++ b/sql/MySQL/0.sql @@ -5,7 +5,7 @@ -- Please consult the SQLite 3 schemata for commented version create table arsse_meta( - `key` varchar(255) primary key, + "key" varchar(255) primary key, value longtext ) character set utf8mb4; @@ -21,9 +21,9 @@ create table arsse_users( create table arsse_users_meta( owner varchar(255) not null references arsse_users(id) on delete cascade on update cascade, - `key` varchar(255) not null, + "key" varchar(255) not null, value varchar(255), - primary key(owner,`key`) + primary key(owner,"key") ) character set utf8mb4; create table arsse_folders( @@ -93,7 +93,7 @@ create table arsse_enclosures( create table arsse_marks( article bigint not null references arsse_articles(id) on delete cascade, subscription bigint not null references arsse_subscriptions(id) on delete cascade on update cascade, - `read` boolean not null default 0, + "read" boolean not null default 0, starred boolean not null default 0, modified datetime(0) not null default CURRENT_TIMESTAMP, primary key(article,subscription) @@ -110,4 +110,4 @@ create table arsse_categories( name varchar(255) ) character set utf8mb4; -insert into arsse_meta(`key`,value) values('schema_version','1'); +insert into arsse_meta("key",value) values('schema_version','1'); diff --git a/sql/MySQL/1.sql b/sql/MySQL/1.sql index eb4ce5f..f7a8542 100644 --- a/sql/MySQL/1.sql +++ b/sql/MySQL/1.sql @@ -8,7 +8,7 @@ create table arsse_sessions ( id varchar(255) primary key, created datetime(0) not null default CURRENT_TIMESTAMP, expires datetime(0) not null, - `user` varchar(255) not null references arsse_users(id) on delete cascade on update cascade + "user" varchar(255) not null references arsse_users(id) on delete cascade on update cascade ) character set utf8mb4; create table arsse_labels ( @@ -30,4 +30,4 @@ create table arsse_label_members ( alter table arsse_marks add column note longtext; -update arsse_meta set value = '2' where `key` = 'schema_version'; +update arsse_meta set value = '2' where "key" = 'schema_version'; diff --git a/sql/MySQL/2.sql b/sql/MySQL/2.sql index d63cbb6..feaf4c9 100644 --- a/sql/MySQL/2.sql +++ b/sql/MySQL/2.sql @@ -20,4 +20,4 @@ alter table arsse_articles convert to character set utf8mb4 collate utf8mb4_unic alter table arsse_categories convert to character set utf8mb4 collate utf8mb4_unicode_ci; alter table arsse_labels convert to character set utf8mb4 collate utf8mb4_unicode_ci; -update arsse_meta set value = '3' where `key` = 'schema_version'; +update arsse_meta set value = '3' where "key" = 'schema_version'; diff --git a/sql/MySQL/3.sql b/sql/MySQL/3.sql index c02df03..32f87a6 100644 --- a/sql/MySQL/3.sql +++ b/sql/MySQL/3.sql @@ -7,4 +7,4 @@ alter table arsse_marks change column modified modified datetime(0); alter table arsse_marks add column touched boolean not null default 0; -update arsse_meta set value = '4' where `key` = 'schema_version'; +update arsse_meta set value = '4' where "key" = 'schema_version'; diff --git a/sql/MySQL/4.sql b/sql/MySQL/4.sql new file mode 100644 index 0000000..aa073a6 --- /dev/null +++ b/sql/MySQL/4.sql @@ -0,0 +1,23 @@ +-- SPDX-License-Identifier: MIT +-- Copyright 2017 J. King, Dustin Wilson et al. +-- See LICENSE and AUTHORS files for details + +-- Please consult the SQLite 3 schemata for commented version + +create table arsse_tags( + id serial primary key, + owner varchar(255) not null references arsse_users(id) on delete cascade on update cascade, + name varchar(255) not null, + modified datetime(0) not null default CURRENT_TIMESTAMP, + unique(owner,name) +) character set utf8mb4 collate utf8mb4_unicode_ci; + +create table arsse_tag_members( + tag bigint not null references arsse_tags(id) on delete cascade, + subscription bigint not null references arsse_subscriptions(id) on delete cascade, + assigned boolean not null default 1, + modified datetime(0) not null default CURRENT_TIMESTAMP, + primary key(tag,subscription) +) character set utf8mb4 collate utf8mb4_unicode_ci; + +update arsse_meta set value = '5' where "key" = 'schema_version'; diff --git a/sql/PostgreSQL/0.sql b/sql/PostgreSQL/0.sql index 3d940f5..d11c2c4 100644 --- a/sql/PostgreSQL/0.sql +++ b/sql/PostgreSQL/0.sql @@ -110,4 +110,4 @@ create table arsse_categories( name text ); -insert into arsse_meta(key,value) values('schema_version','1'); +insert into arsse_meta("key",value) values('schema_version','1'); diff --git a/sql/PostgreSQL/1.sql b/sql/PostgreSQL/1.sql index 1549fd5..d2a5480 100644 --- a/sql/PostgreSQL/1.sql +++ b/sql/PostgreSQL/1.sql @@ -30,4 +30,4 @@ create table arsse_label_members ( alter table arsse_marks add column note text not null default ''; -update arsse_meta set value = '2' where key = 'schema_version'; +update arsse_meta set value = '2' where "key" = 'schema_version'; diff --git a/sql/PostgreSQL/2.sql b/sql/PostgreSQL/2.sql index 847edb7..33863fb 100644 --- a/sql/PostgreSQL/2.sql +++ b/sql/PostgreSQL/2.sql @@ -13,4 +13,4 @@ alter table arsse_articles alter column author type text collate "und-x-icu"; alter table arsse_categories alter column name type text collate "und-x-icu"; alter table arsse_labels alter column name type text collate "und-x-icu"; -update arsse_meta set value = '3' where key = 'schema_version'; +update arsse_meta set value = '3' where "key" = 'schema_version'; diff --git a/sql/PostgreSQL/3.sql b/sql/PostgreSQL/3.sql index 2290ae5..091cf46 100644 --- a/sql/PostgreSQL/3.sql +++ b/sql/PostgreSQL/3.sql @@ -8,4 +8,4 @@ alter table arsse_marks alter column modified drop default; alter table arsse_marks alter column modified drop not null; alter table arsse_marks add column touched smallint not null default 0; -update arsse_meta set value = '4' where key = 'schema_version'; +update arsse_meta set value = '4' where "key" = 'schema_version'; diff --git a/sql/PostgreSQL/4.sql b/sql/PostgreSQL/4.sql new file mode 100644 index 0000000..e0cd8eb --- /dev/null +++ b/sql/PostgreSQL/4.sql @@ -0,0 +1,23 @@ +-- SPDX-License-Identifier: MIT +-- Copyright 2017 J. King, Dustin Wilson et al. +-- See LICENSE and AUTHORS files for details + +-- Please consult the SQLite 3 schemata for commented version + +create table arsse_tags( + id bigserial primary key, + owner text not null references arsse_users(id) on delete cascade on update cascade, + name text not null collate "und-x-icu", + modified timestamp(0) without time zone not null default CURRENT_TIMESTAMP, + unique(owner,name) +); + +create table arsse_tag_members( + tag bigint not null references arsse_tags(id) on delete cascade, + subscription bigint not null references arsse_subscriptions(id) on delete cascade, + assigned smallint not null default 1, + modified timestamp(0) without time zone not null default CURRENT_TIMESTAMP, + primary key(tag,subscription) +); + +update arsse_meta set value = '5' where "key" = 'schema_version'; diff --git a/sql/SQLite3/0.sql b/sql/SQLite3/0.sql index 7a9dea6..97f5451 100644 --- a/sql/SQLite3/0.sql +++ b/sql/SQLite3/0.sql @@ -130,4 +130,4 @@ create table arsse_categories( -- set version marker pragma user_version = 1; -insert into arsse_meta(key,value) values('schema_version','1'); +insert into arsse_meta("key",value) values('schema_version','1'); diff --git a/sql/SQLite3/1.sql b/sql/SQLite3/1.sql index dc7862d..3817645 100644 --- a/sql/SQLite3/1.sql +++ b/sql/SQLite3/1.sql @@ -20,7 +20,7 @@ create table arsse_labels( ); create table arsse_label_members( --- uabels assignments for articles +-- label assignments for articles label integer not null references arsse_labels(id) on delete cascade, -- label ID associated to an article; label IDs belong to a user article integer not null references arsse_articles(id) on delete cascade, -- article associated to a label subscription integer not null references arsse_subscriptions(id) on delete cascade, -- Subscription is included so that records are deleted when a subscription is removed @@ -48,4 +48,4 @@ alter table arsse_marks_new rename to arsse_marks; -- set version marker pragma user_version = 2; -update arsse_meta set value = '2' where key = 'schema_version'; +update arsse_meta set value = '2' where "key" = 'schema_version'; diff --git a/sql/SQLite3/2.sql b/sql/SQLite3/2.sql index b378467..14a253d 100644 --- a/sql/SQLite3/2.sql +++ b/sql/SQLite3/2.sql @@ -121,4 +121,4 @@ alter table arsse_labels_new rename to arsse_labels; -- set version marker pragma user_version = 3; -update arsse_meta set value = '3' where key = 'schema_version'; +update arsse_meta set value = '3' where "key" = 'schema_version'; diff --git a/sql/SQLite3/3.sql b/sql/SQLite3/3.sql index 0d58324..087bf32 100644 --- a/sql/SQLite3/3.sql +++ b/sql/SQLite3/3.sql @@ -24,4 +24,4 @@ reindex nocase; -- set version marker pragma user_version = 4; -update arsse_meta set value = '4' where key = 'schema_version'; +update arsse_meta set value = '4' where "key" = 'schema_version'; diff --git a/sql/SQLite3/4.sql b/sql/SQLite3/4.sql new file mode 100644 index 0000000..aa7cfbd --- /dev/null +++ b/sql/SQLite3/4.sql @@ -0,0 +1,25 @@ +-- SPDX-License-Identifier: MIT +-- Copyright 2017 J. King, Dustin Wilson et al. +-- See LICENSE and AUTHORS files for details + +create table arsse_tags( +-- user-defined subscription tags + id integer primary key, -- numeric ID + owner text not null references arsse_users(id) on delete cascade on update cascade, -- owning user + name text not null collate nocase, -- tag text + modified text not null default CURRENT_TIMESTAMP, -- time at which the tag was last modified + unique(owner,name) +); + +create table arsse_tag_members( +-- tag assignments for subscriptions + tag integer not null references arsse_tags(id) on delete cascade, -- tag ID associated to a subscription + subscription integer not null references arsse_subscriptions(id) on delete cascade, -- Subscription associated to a tag + assigned boolean not null default 1, -- whether the association is current, to support soft deletion + modified text not null default CURRENT_TIMESTAMP, -- time at which the association was last made or unmade + primary key(tag,subscription) -- only one association of a given tag to a given subscription +) without rowid; + +-- set version marker +pragma user_version = 5; +update arsse_meta set value = '5' where "key" = 'schema_version'; From ed22090e4999b6d6dba1e0f8f1cd615baa100440 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 4 Mar 2019 11:05:46 -0500 Subject: [PATCH 032/142] Work around various SQLite-related problems - WAL mode was not getting set properly - Queries using the PDO driver could fail because PDO sucks --- lib/AbstractException.php | 1 + lib/Db/ExceptionRetry.php | 10 +++++++ lib/Db/SQLite3/AbstractPDODriver.php | 11 +++++++ lib/Db/SQLite3/Driver.php | 5 ++++ lib/Db/SQLite3/ExceptionBuilder.php | 3 ++ lib/Db/SQLite3/PDODriver.php | 40 +++++++++++++++++++++++-- lib/Db/SQLite3/PDOStatement.php | 19 ++++++++++++ locale/en.php | 1 + sql/SQLite3/0.sql | 3 -- tests/cases/DatabaseDrivers/SQLite3.php | 2 +- 10 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 lib/Db/ExceptionRetry.php create mode 100644 lib/Db/SQLite3/AbstractPDODriver.php diff --git a/lib/AbstractException.php b/lib/AbstractException.php index 7df2263..a524da6 100644 --- a/lib/AbstractException.php +++ b/lib/AbstractException.php @@ -45,6 +45,7 @@ abstract class AbstractException extends \Exception { "Db/Exception.savepointInvalid" => 10226, "Db/Exception.savepointStale" => 10227, "Db/Exception.resultReused" => 10228, + "Db/ExceptionRetry.schemaChange" => 10229, "Db/ExceptionInput.missing" => 10231, "Db/ExceptionInput.whitespace" => 10232, "Db/ExceptionInput.tooLong" => 10233, diff --git a/lib/Db/ExceptionRetry.php b/lib/Db/ExceptionRetry.php new file mode 100644 index 0000000..be4769a --- /dev/null +++ b/lib/Db/ExceptionRetry.php @@ -0,0 +1,10 @@ +exec("PRAGMA journal_mode = wal"); + } // turn off foreign keys $this->exec("PRAGMA foreign_keys = no"); // run the generic updater diff --git a/lib/Db/SQLite3/ExceptionBuilder.php b/lib/Db/SQLite3/ExceptionBuilder.php index 9e3bfff..c87e62f 100644 --- a/lib/Db/SQLite3/ExceptionBuilder.php +++ b/lib/Db/SQLite3/ExceptionBuilder.php @@ -7,6 +7,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse\Db\SQLite3; use JKingWeb\Arsse\Db\Exception; +use JKingWeb\Arsse\Db\ExceptionRetry; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\ExceptionTimeout; @@ -19,6 +20,8 @@ trait ExceptionBuilder { switch ($code) { case Driver::SQLITE_BUSY: return [ExceptionTimeout::class, 'general', $msg]; + case Driver::SQLITE_SCHEMA: + return [ExceptionRetry::class, 'schemaChange', $msg]; case Driver::SQLITE_CONSTRAINT: return [ExceptionInput::class, 'engineConstraintViolation', $msg]; case Driver::SQLITE_MISMATCH: diff --git a/lib/Db/SQLite3/PDODriver.php b/lib/Db/SQLite3/PDODriver.php index c36a3c1..b1cff19 100644 --- a/lib/Db/SQLite3/PDODriver.php +++ b/lib/Db/SQLite3/PDODriver.php @@ -11,9 +11,7 @@ use JKingWeb\Arsse\Db\Exception; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\ExceptionTimeout; -class PDODriver extends Driver { - use \JKingWeb\Arsse\Db\PDODriver; - +class PDODriver extends AbstractPDODriver { protected $db; public static function requirementsMet(): bool { @@ -49,4 +47,40 @@ class PDODriver extends Driver { public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement { return new PDOStatement($this->db, $query, $paramTypes); } + + /** @codeCoverageIgnore */ + public function exec(string $query): bool { + // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), + // we have to retry ourselves in cases of schema changes + // the SQLite3 class is not similarly affected + $attempts = 0; + retry: + try { + return parent::exec($query); + } catch (\JKingWeb\Arsse\Db\ExceptionRetry $e) { + if (++$attempts > 50) { + throw $e; + } else { + goto retry; + } + } + } + + /** @codeCoverageIgnore */ + public function query(string $query): \JKingWeb\Arsse\Db\Result { + // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), + // we have to retry ourselves in cases of schema changes + // the SQLite3 class is not similarly affected + $attempts = 0; + retry: + try { + return parent::query($query); + } catch (\JKingWeb\Arsse\Db\ExceptionRetry $e) { + if (++$attempts > 50) { + throw $e; + } else { + goto retry; + } + } + } } diff --git a/lib/Db/SQLite3/PDOStatement.php b/lib/Db/SQLite3/PDOStatement.php index 7e7642d..166fe31 100644 --- a/lib/Db/SQLite3/PDOStatement.php +++ b/lib/Db/SQLite3/PDOStatement.php @@ -9,4 +9,23 @@ namespace JKingWeb\Arsse\Db\SQLite3; class PDOStatement extends \JKingWeb\Arsse\Db\PDOStatement { use ExceptionBuilder; use \JKingWeb\Arsse\Db\PDOError; + + /** @codeCoverageIgnore */ + public function runArray(array $values = []): \JKingWeb\Arsse\Db\Result { + // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), + // we have to retry ourselves in cases of schema changes + // the SQLite3 class is not similarly affected + $attempts = 0; + retry: + try { + return parent::runArray($values); + } catch (\JKingWeb\Arsse\Db\ExceptionRetry $e) { + if (++$attempts > 50) { + throw $e; + } else { + $this->st = $this->db->prepare($this->st->queryString); + goto retry; + } + } + } } diff --git a/locale/en.php b/locale/en.php index 5e8ad0f..ddbf118 100644 --- a/locale/en.php +++ b/locale/en.php @@ -120,6 +120,7 @@ return [ 'Exception.JKingWeb/Arsse/Db/Exception.savepointStale' => 'Tried to {action} stale savepoint {index}', // indicates programming error 'Exception.JKingWeb/Arsse/Db/Exception.resultReused' => 'Result set already iterated', + 'Exception.JKingWeb/Arsse/Db/ExceptionRetry.schemaChange' => '{0}', 'Exception.JKingWeb/Arsse/Db/ExceptionInput.missing' => 'Required field "{field}" missing while performing action "{action}"', 'Exception.JKingWeb/Arsse/Db/ExceptionInput.whitespace' => 'Field "{field}" of action "{action}" may not contain only whitespace', 'Exception.JKingWeb/Arsse/Db/ExceptionInput.tooLong' => 'Field "{field}" of action "{action}" has a maximum length of {max}', diff --git a/sql/SQLite3/0.sql b/sql/SQLite3/0.sql index 97f5451..d9a9b9f 100644 --- a/sql/SQLite3/0.sql +++ b/sql/SQLite3/0.sql @@ -2,9 +2,6 @@ -- Copyright 2017 J. King, Dustin Wilson et al. -- See LICENSE and AUTHORS files for details --- Make the database WAL-journalled; this is persitent -PRAGMA journal_mode = wal; - create table arsse_meta( -- application metadata key text primary key not null, -- metadata key diff --git a/tests/cases/DatabaseDrivers/SQLite3.php b/tests/cases/DatabaseDrivers/SQLite3.php index e927d41..880539a 100644 --- a/tests/cases/DatabaseDrivers/SQLite3.php +++ b/tests/cases/DatabaseDrivers/SQLite3.php @@ -28,7 +28,7 @@ trait SQLite3 { } public static function dbTableList($db): array { - $listTables = "SELECT name from sqlite_master where type = 'table' and name like 'arsse_%'"; + $listTables = "SELECT name from sqlite_master where type = 'table' and name like 'arsse^_%' escape '^'"; if ($db instanceof Driver) { $tables = $db->query($listTables)->getAll(); $tables = sizeof($tables) ? array_column($tables, "name") : []; From 6000d80b7bda8f0c76130bab70217635101b2517 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 4 Mar 2019 11:05:46 -0500 Subject: [PATCH 033/142] Work around various SQLite-related problems - WAL mode was not getting set properly - Queries using the PDO driver could fail because PDO sucks --- lib/AbstractException.php | 1 + lib/Db/ExceptionRetry.php | 10 +++++++ lib/Db/SQLite3/AbstractPDODriver.php | 11 +++++++ lib/Db/SQLite3/Driver.php | 5 ++++ lib/Db/SQLite3/ExceptionBuilder.php | 3 ++ lib/Db/SQLite3/PDODriver.php | 40 +++++++++++++++++++++++-- lib/Db/SQLite3/PDOStatement.php | 19 ++++++++++++ locale/en.php | 1 + sql/SQLite3/0.sql | 3 -- tests/cases/DatabaseDrivers/SQLite3.php | 2 +- 10 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 lib/Db/ExceptionRetry.php create mode 100644 lib/Db/SQLite3/AbstractPDODriver.php diff --git a/lib/AbstractException.php b/lib/AbstractException.php index 7df2263..a524da6 100644 --- a/lib/AbstractException.php +++ b/lib/AbstractException.php @@ -45,6 +45,7 @@ abstract class AbstractException extends \Exception { "Db/Exception.savepointInvalid" => 10226, "Db/Exception.savepointStale" => 10227, "Db/Exception.resultReused" => 10228, + "Db/ExceptionRetry.schemaChange" => 10229, "Db/ExceptionInput.missing" => 10231, "Db/ExceptionInput.whitespace" => 10232, "Db/ExceptionInput.tooLong" => 10233, diff --git a/lib/Db/ExceptionRetry.php b/lib/Db/ExceptionRetry.php new file mode 100644 index 0000000..be4769a --- /dev/null +++ b/lib/Db/ExceptionRetry.php @@ -0,0 +1,10 @@ +exec("PRAGMA journal_mode = wal"); + } // turn off foreign keys $this->exec("PRAGMA foreign_keys = no"); // run the generic updater diff --git a/lib/Db/SQLite3/ExceptionBuilder.php b/lib/Db/SQLite3/ExceptionBuilder.php index 9e3bfff..c87e62f 100644 --- a/lib/Db/SQLite3/ExceptionBuilder.php +++ b/lib/Db/SQLite3/ExceptionBuilder.php @@ -7,6 +7,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse\Db\SQLite3; use JKingWeb\Arsse\Db\Exception; +use JKingWeb\Arsse\Db\ExceptionRetry; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\ExceptionTimeout; @@ -19,6 +20,8 @@ trait ExceptionBuilder { switch ($code) { case Driver::SQLITE_BUSY: return [ExceptionTimeout::class, 'general', $msg]; + case Driver::SQLITE_SCHEMA: + return [ExceptionRetry::class, 'schemaChange', $msg]; case Driver::SQLITE_CONSTRAINT: return [ExceptionInput::class, 'engineConstraintViolation', $msg]; case Driver::SQLITE_MISMATCH: diff --git a/lib/Db/SQLite3/PDODriver.php b/lib/Db/SQLite3/PDODriver.php index c36a3c1..b1cff19 100644 --- a/lib/Db/SQLite3/PDODriver.php +++ b/lib/Db/SQLite3/PDODriver.php @@ -11,9 +11,7 @@ use JKingWeb\Arsse\Db\Exception; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\ExceptionTimeout; -class PDODriver extends Driver { - use \JKingWeb\Arsse\Db\PDODriver; - +class PDODriver extends AbstractPDODriver { protected $db; public static function requirementsMet(): bool { @@ -49,4 +47,40 @@ class PDODriver extends Driver { public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement { return new PDOStatement($this->db, $query, $paramTypes); } + + /** @codeCoverageIgnore */ + public function exec(string $query): bool { + // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), + // we have to retry ourselves in cases of schema changes + // the SQLite3 class is not similarly affected + $attempts = 0; + retry: + try { + return parent::exec($query); + } catch (\JKingWeb\Arsse\Db\ExceptionRetry $e) { + if (++$attempts > 50) { + throw $e; + } else { + goto retry; + } + } + } + + /** @codeCoverageIgnore */ + public function query(string $query): \JKingWeb\Arsse\Db\Result { + // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), + // we have to retry ourselves in cases of schema changes + // the SQLite3 class is not similarly affected + $attempts = 0; + retry: + try { + return parent::query($query); + } catch (\JKingWeb\Arsse\Db\ExceptionRetry $e) { + if (++$attempts > 50) { + throw $e; + } else { + goto retry; + } + } + } } diff --git a/lib/Db/SQLite3/PDOStatement.php b/lib/Db/SQLite3/PDOStatement.php index 7e7642d..166fe31 100644 --- a/lib/Db/SQLite3/PDOStatement.php +++ b/lib/Db/SQLite3/PDOStatement.php @@ -9,4 +9,23 @@ namespace JKingWeb\Arsse\Db\SQLite3; class PDOStatement extends \JKingWeb\Arsse\Db\PDOStatement { use ExceptionBuilder; use \JKingWeb\Arsse\Db\PDOError; + + /** @codeCoverageIgnore */ + public function runArray(array $values = []): \JKingWeb\Arsse\Db\Result { + // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), + // we have to retry ourselves in cases of schema changes + // the SQLite3 class is not similarly affected + $attempts = 0; + retry: + try { + return parent::runArray($values); + } catch (\JKingWeb\Arsse\Db\ExceptionRetry $e) { + if (++$attempts > 50) { + throw $e; + } else { + $this->st = $this->db->prepare($this->st->queryString); + goto retry; + } + } + } } diff --git a/locale/en.php b/locale/en.php index 5e8ad0f..ddbf118 100644 --- a/locale/en.php +++ b/locale/en.php @@ -120,6 +120,7 @@ return [ 'Exception.JKingWeb/Arsse/Db/Exception.savepointStale' => 'Tried to {action} stale savepoint {index}', // indicates programming error 'Exception.JKingWeb/Arsse/Db/Exception.resultReused' => 'Result set already iterated', + 'Exception.JKingWeb/Arsse/Db/ExceptionRetry.schemaChange' => '{0}', 'Exception.JKingWeb/Arsse/Db/ExceptionInput.missing' => 'Required field "{field}" missing while performing action "{action}"', 'Exception.JKingWeb/Arsse/Db/ExceptionInput.whitespace' => 'Field "{field}" of action "{action}" may not contain only whitespace', 'Exception.JKingWeb/Arsse/Db/ExceptionInput.tooLong' => 'Field "{field}" of action "{action}" has a maximum length of {max}', diff --git a/sql/SQLite3/0.sql b/sql/SQLite3/0.sql index 7a9dea6..5466629 100644 --- a/sql/SQLite3/0.sql +++ b/sql/SQLite3/0.sql @@ -2,9 +2,6 @@ -- Copyright 2017 J. King, Dustin Wilson et al. -- See LICENSE and AUTHORS files for details --- Make the database WAL-journalled; this is persitent -PRAGMA journal_mode = wal; - create table arsse_meta( -- application metadata key text primary key not null, -- metadata key diff --git a/tests/cases/DatabaseDrivers/SQLite3.php b/tests/cases/DatabaseDrivers/SQLite3.php index e927d41..880539a 100644 --- a/tests/cases/DatabaseDrivers/SQLite3.php +++ b/tests/cases/DatabaseDrivers/SQLite3.php @@ -28,7 +28,7 @@ trait SQLite3 { } public static function dbTableList($db): array { - $listTables = "SELECT name from sqlite_master where type = 'table' and name like 'arsse_%'"; + $listTables = "SELECT name from sqlite_master where type = 'table' and name like 'arsse^_%' escape '^'"; if ($db instanceof Driver) { $tables = $db->query($listTables)->getAll(); $tables = sizeof($tables) ? array_column($tables, "name") : []; From 4945f8baa3c3025e5fe6d96e4e7bb1c27c276332 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 5 Mar 2019 19:22:01 -0500 Subject: [PATCH 034/142] Clarify various SQL queries --- lib/Database.php | 180 ++++++++++++-------------- tests/cases/Database/SeriesFolder.php | 63 +++++++-- 2 files changed, 139 insertions(+), 104 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index cecfb33..a5c7781 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -428,14 +428,18 @@ class Database { $parent = $this->folderValidateId($user, $parent)['id']; $q = new Query( "SELECT - id,name,parent, - (select count(*) from arsse_folders as parents where coalesce(parents.parent,0) = coalesce(arsse_folders.id,0)) as children, - (select count(*) from arsse_subscriptions where coalesce(folder,0) = coalesce(arsse_folders.id,0)) as feeds - FROM arsse_folders" + id, + name, + arsse_folders.parent as parent, + coalesce(children,0) as children, + coalesce(feeds,0) as feeds + FROM arsse_folders + left join (SELECT parent,count(id) as children from arsse_folders group by parent) as child_stats on child_stats.parent = arsse_folders.id + left join (SELECT folder,count(id) as feeds from arsse_subscriptions group by folder) as sub_stats on sub_stats.folder = arsse_folders.id" ); if (!$recursive) { $q->setWhere("owner = ?", "str", $user); - $q->setWhere("coalesce(parent,0) = ?", "strict int", $parent); + $q->setWhere("coalesce(arsse_folders.parent,0) = ?", "strict int", $parent); } else { $q->setCTE("folders", "SELECT id from arsse_folders where owner = ? and coalesce(parent,0) = ? union select arsse_folders.id from arsse_folders join folders on arsse_folders.parent=folders.id", ["str", "strict int"], [$user, $parent]); $q->setWhere("id in (SELECT id from folders)"); @@ -687,22 +691,23 @@ class Database { $q = new Query( "SELECT arsse_subscriptions.id as id, - feed,url,favicon,source,folder,pinned,err_count,err_msg,order_type,added, + arsse_subscriptions.feed, + url,favicon,source,folder,pinned,err_count,err_msg,order_type,added, arsse_feeds.updated as updated, topmost.top as top_folder, coalesce(arsse_subscriptions.title, arsse_feeds.title) as title, - (SELECT count(*) from arsse_articles where feed = arsse_subscriptions.feed) - (SELECT count(*) from arsse_marks where subscription = arsse_subscriptions.id and \"read\" = 1) as unread - from arsse_subscriptions - join userdata on userid = owner - join arsse_feeds on feed = arsse_feeds.id - left join topmost on folder=f_id" + (articles - marked) as unread + FROM arsse_subscriptions + left join topmost on topmost.f_id = arsse_subscriptions.folder + join arsse_feeds on arsse_feeds.id = arsse_subscriptions.feed + left join (select feed, count(*) as articles from arsse_articles group by feed) as article_stats on article_stats.feed = arsse_subscriptions.feed + left join (select subscription, sum(\"read\") as marked from arsse_marks group by subscription) as mark_stats on mark_stats.subscription = arsse_subscriptions.id" ); + $q->setWhere("arsse_subscriptions.owner = ?", ["str"], [$user]); $nocase = $this->db->sqlToken("nocase"); $q->setOrder("pinned desc, coalesce(arsse_subscriptions.title, arsse_feeds.title) collate $nocase"); - // define common table expressions - $q->setCTE("userdata(userid)", "SELECT ?", "str", $user); // the subject user; this way we only have to pass it to prepare() once // topmost folders belonging to the user - $q->setCTE("topmost(f_id,top)", "SELECT id,id from arsse_folders join userdata on owner = userid where parent is null union select id,top from arsse_folders join topmost on parent=f_id"); + $q->setCTE("topmost(f_id,top)", "SELECT id,id from arsse_folders where owner = ? and parent is null union select id,top from arsse_folders join topmost on parent=f_id", ["str"], [$user]); if ($id) { // this condition facilitates the implementation of subscriptionPropertiesGet, which would otherwise have to duplicate the complex query; it takes precedence over a specified folder // if an ID is specified, add a suitable WHERE condition and bindings @@ -801,7 +806,7 @@ class Database { * - "order_type": Whether articles should be sorted in reverse cronological order (2), chronological order (1), or the default (0) * * @param string $user The user whose subscription is to be modified - * @param integer|null $id the numeric identifier of the subscription to modfify + * @param integer $id the numeric identifier of the subscription to modfify * @param array $data An associative array of properties to modify; any keys not specified will be left unchanged */ public function subscriptionPropertiesSet(string $user, $id, array $data): bool { @@ -873,7 +878,7 @@ class Database { * Returns an associative array containing the id of the subscription and the id of the underlying newsfeed * * @param string $user The user who owns the subscription to be validated - * @param integer|null $id The identifier of the subscription to validate + * @param integer $id The identifier of the subscription to validate * @param boolean $subject Whether the subscription is the subject (true) rather than the object (false) of the operation being performed; this only affects the semantics of the error message if validation fails */ protected function subscriptionValidateId(string $user, $id, bool $subject = false): array { @@ -1056,9 +1061,9 @@ class Database { public function feedCleanup(): bool { $tr = $this->begin(); // first unmark any feeds which are no longer orphaned - $this->db->query("UPDATE arsse_feeds set orphaned = null where exists(SELECT id from arsse_subscriptions where feed = arsse_feeds.id)"); + $this->db->query("WITH active_feeds as (select id from arsse_feeds left join (select feed, count(id) as count from arsse_subscriptions group by feed) as sub_stats on sub_stats.feed = arsse_feeds.id where orphaned is not null and count is not null) UPDATE arsse_feeds set orphaned = null where id in (select id from active_feeds)"); // next mark any newly orphaned feeds with the current date and time - $this->db->query("UPDATE arsse_feeds set orphaned = CURRENT_TIMESTAMP where orphaned is null and not exists(SELECT id from arsse_subscriptions where feed = arsse_feeds.id)"); + $this->db->query("WITH orphaned_feeds as (select id from arsse_feeds left join (select feed, count(id) as count from arsse_subscriptions group by feed) as sub_stats on sub_stats.feed = arsse_feeds.id where orphaned is null and count is null) UPDATE arsse_feeds set orphaned = CURRENT_TIMESTAMP where id in (select id from orphaned_feeds)"); // finally delete feeds that have been orphaned longer than the retention period, if a a purge threshold has been specified if (Arsse::$conf->purgeFeeds) { $limit = Date::sub(Arsse::$conf->purgeFeeds); @@ -1500,7 +1505,7 @@ class Database { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } $id = $this->articleValidateId($user, $id)['article']; - $out = $this->db->prepare("SELECT id,name from arsse_labels where owner = ? and exists(select id from arsse_label_members where article = ? and label = arsse_labels.id and assigned = 1)", "str", "int")->run($user, $id)->getAll(); + $out = $this->db->prepare("SELECT id, name from arsse_labels join arsse_label_members on arsse_label_members.label = arsse_labels.id where owner = ? and article = ? and assigned = 1", "str", "int")->run($user, $id)->getAll(); // flatten the result to return just the label ID or name, sorted $out = $out ? array_column($out, !$byName ? "id" : "name") : []; sort($out); @@ -1525,30 +1530,16 @@ class Database { /** Deletes from the database articles which are beyond the configured clean-up threshold */ public function articleCleanup(): bool { $query = $this->db->prepare( - "WITH target_feed(id,subs) as (". - "SELECT - id, (select count(*) from arsse_subscriptions where feed = arsse_feeds.id) as subs - from arsse_feeds where id = ?". - "), latest_editions(article,edition) as (". - "SELECT article,max(id) from arsse_editions group by article". - "), excepted_articles(id,edition) as (". - "SELECT - arsse_articles.id as id, - latest_editions.edition as edition - from arsse_articles - join target_feed on arsse_articles.feed = target_feed.id - join latest_editions on arsse_articles.id = latest_editions.article - order by edition desc limit ?". - ") ". - "DELETE from arsse_articles where - feed = (select max(id) from target_feed) - and id not in (select id from excepted_articles) - and (select count(*) from arsse_marks where article = arsse_articles.id and starred = 1) = 0 - and ( - coalesce((select max(modified) from arsse_marks where article = arsse_articles.id),modified) <= ? - or ((select max(subs) from target_feed) = (select count(*) from arsse_marks where article = arsse_articles.id and \"read\" = 1) and coalesce((select max(modified) from arsse_marks where article = arsse_articles.id),modified) <= ?) + "WITH RECURSIVE + exempt_articles as (SELECT id from arsse_articles join (SELECT article, max(id) as edition from arsse_editions group by article) as latest_editions on arsse_articles.id = latest_editions.article where feed = ? order by edition desc limit ?), + target_articles as ( + select id from arsse_articles + left join (select article, sum(starred) as starred, sum(\"read\") as \"read\", max(arsse_marks.modified) as marked_date from arsse_marks join arsse_subscriptions on arsse_subscriptions.id = arsse_marks.subscription group by article) as mark_stats on mark_stats.article = arsse_articles.id + left join (select feed, count(*) as subs from arsse_subscriptions group by feed) as feed_stats on feed_stats.feed = arsse_articles.feed + where arsse_articles.feed = ? and coalesce(starred,0) = 0 and (coalesce(marked_date,modified) <= ? or (coalesce(\"read\",0) = coalesce(subs,0) and coalesce(marked_date,modified) <= ?)) ) - ", + DELETE FROM arsse_articles WHERE id not in (select id from exempt_articles) and id in (select id from target_articles)", + "int", "int", "int", "datetime", @@ -1564,7 +1555,7 @@ class Database { } $feeds = $this->db->query("SELECT id, size from arsse_feeds")->getAll(); foreach ($feeds as $feed) { - $query->run($feed['id'], $feed['size'], $limitUnread, $limitRead); + $query->run($feed['id'], $feed['size'], $feed['id'], $limitUnread, $limitRead); } return true; } @@ -1574,21 +1565,19 @@ class Database { * Returns an associative array containing the id and latest edition of the article if it exists * * @param string $user The user who owns the article to be validated - * @param integer|null $id The identifier of the article to validate + * @param integer $id The identifier of the article to validate */ protected function articleValidateId(string $user, $id): array { if (!ValueInfo::id($id)) { throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "article", 'type' => "int > 0"]); // @codeCoverageIgnore } $out = $this->db->prepare( - "SELECT - arsse_articles.id as article, - (select max(id) from arsse_editions where article = arsse_articles.id) as edition - FROM arsse_articles - join arsse_feeds on arsse_feeds.id = arsse_articles.feed - join arsse_subscriptions on arsse_subscriptions.feed = arsse_feeds.id - WHERE - arsse_articles.id = ? and arsse_subscriptions.owner = ?", + "SELECT articles.article as article, max(arsse_editions.id) as edition from ( + select arsse_articles.id as article + FROM arsse_articles + join arsse_subscriptions on arsse_subscriptions.feed = arsse_articles.feed + WHERE arsse_articles.id = ? and arsse_subscriptions.owner = ? + ) as articles join arsse_editions on arsse_editions.article = articles.article group by articles.article", "int", "str" )->run($id, $user)->getRow(); @@ -1603,7 +1592,7 @@ class Database { * Returns an associative array containing the edition id, article id, and latest edition of the edition if it exists * * @param string $user The user who owns the edition to be validated - * @param integer|null $id The identifier of the edition to validate + * @param integer $id The identifier of the edition to validate */ protected function articleValidateEdition(string $user, int $id): array { if (!ValueInfo::id($id)) { @@ -1611,15 +1600,12 @@ class Database { } $out = $this->db->prepare( "SELECT - arsse_editions.id as edition, - arsse_editions.article as article, - (arsse_editions.id = (select max(id) from arsse_editions where article = arsse_editions.article)) as current - FROM arsse_editions - join arsse_articles on arsse_editions.article = arsse_articles.id - join arsse_feeds on arsse_feeds.id = arsse_articles.feed - join arsse_subscriptions on arsse_subscriptions.feed = arsse_feeds.id - WHERE - arsse_editions.id = ? and arsse_subscriptions.owner = ?", + arsse_editions.id, arsse_editions.article, edition_stats.edition as current + from arsse_editions + join arsse_articles on arsse_articles.id = arsse_editions.article + join arsse_subscriptions on arsse_subscriptions.feed = arsse_articles.feed + join (select article, max(id) as edition from arsse_editions group by article) as edition_stats on edition_stats.article = arsse_editions.article + where arsse_editions.id = ? and arsse_subscriptions.owner = ?", "int", "str" )->run($id, $user)->getRow(); @@ -1693,18 +1679,28 @@ class Database { return $this->db->prepare( "SELECT * FROM ( SELECT - id,name, - (select count(*) from arsse_label_members where label = id and assigned = 1) as articles, - (select count(*) from arsse_label_members - join arsse_marks on arsse_label_members.article = arsse_marks.article and arsse_label_members.subscription = arsse_marks.subscription - where label = id and assigned = 1 and \"read\" = 1 - ) as \"read\" - FROM arsse_labels where owner = ?) as label_data + id,name,coalesce(articles,0) as articles,coalesce(marked,0) as \"read\" + from arsse_labels + left join ( + SELECT label, sum(assigned) as articles from arsse_label_members group by label + ) as label_stats on label_stats.label = arsse_labels.id + left join ( + SELECT + label, sum(\"read\") as marked + from arsse_marks + join arsse_subscriptions on arsse_subscriptions.id = arsse_marks.subscription + join arsse_label_members on arsse_label_members.article = arsse_marks.article + where arsse_subscriptions.owner = ? + group by label + ) as mark_stats on mark_stats.label = arsse_labels.id + WHERE owner = ? + ) as label_data where articles >= ? order by name ", "str", + "str", "int" - )->run($user, !$includeEmpty); + )->run($user, $user, !$includeEmpty); } /** Deletes a label from the database @@ -1751,17 +1747,26 @@ class Database { $type = $byName ? "str" : "int"; $out = $this->db->prepare( "SELECT - id,name, - (select count(*) from arsse_label_members where label = id and assigned = 1) as articles, - (select count(*) from arsse_label_members - join arsse_marks on arsse_label_members.article = arsse_marks.article and arsse_label_members.subscription = arsse_marks.subscription - where label = id and assigned = 1 and \"read\" = 1 - ) as \"read\" - FROM arsse_labels where $field = ? and owner = ? + id,name,coalesce(articles,0) as articles,coalesce(marked,0) as \"read\" + FROM arsse_labels + left join ( + SELECT label, sum(assigned) as articles from arsse_label_members group by label + ) as label_stats on label_stats.label = arsse_labels.id + left join ( + SELECT + label, sum(\"read\") as marked + from arsse_marks + join arsse_subscriptions on arsse_subscriptions.id = arsse_marks.subscription + join arsse_label_members on arsse_label_members.article = arsse_marks.article + where arsse_subscriptions.owner = ? + group by label + ) as mark_stats on mark_stats.label = arsse_labels.id + WHERE $field = ? and owner = ? ", + "str", $type, "str" - )->run($id, $user)->getRow(); + )->run($user, $id, $user)->getRow(); if (!$out) { throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "label", 'id' => $id]); } @@ -1846,27 +1851,14 @@ class Database { $tr = $this->begin(); // first update any existing entries with the removal or re-addition of their association $q = $this->articleQuery($user, $context); - $q->setWhere("exists(select article from arsse_label_members where label = ? and article = arsse_articles.id)", "int", $id); $q->pushCTE("target_articles"); - $q->setBody( - "UPDATE arsse_label_members set assigned = ?, modified = CURRENT_TIMESTAMP where label = ? and assigned <> ? and article in (select id from target_articles)", - ["bool","int","bool"], - [!$remove, $id, !$remove] - ); + $q->setBody("UPDATE arsse_label_members set assigned = ?, modified = CURRENT_TIMESTAMP where label = ? and assigned <> ? and article in (select id from target_articles)", ["bool","int","bool"], [!$remove, $id, !$remove]); $out += $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes(); // next, if we're not removing, add any new entries that need to be added if (!$remove) { - $q = $this->articleQuery($user, $context, ["id", "feed"]); - $q->setWhere("not exists(select article from arsse_label_members where label = ? and article = arsse_articles.id)", "int", $id); + $q = $this->articleQuery($user, $context, ["id", "subscription"]); $q->pushCTE("target_articles"); - $q->setBody( - "SELECT - ?,id, - (select id from arsse_subscriptions where owner = ? and arsse_subscriptions.feed = target_articles.feed) - FROM target_articles", - ["int", "str"], - [$id, $user] - ); + $q->setBody("SELECT ?,id,subscription from target_articles where id not in (select article from arsse_label_members where label = ?)", ["int", "int"], [$id, $id]); $out += $this->db->prepare("INSERT INTO arsse_label_members(label,article,subscription) ".$q->getQuery(), $q->getTypes())->run($q->getValues())->changes(); } // commit the transaction diff --git a/tests/cases/Database/SeriesFolder.php b/tests/cases/Database/SeriesFolder.php index 7265f07..99c9f1a 100644 --- a/tests/cases/Database/SeriesFolder.php +++ b/tests/cases/Database/SeriesFolder.php @@ -49,6 +49,49 @@ trait SeriesFolder { [6, "john.doe@example.com", 2, "Politics"], ] ], + 'arsse_feeds' => [ + 'columns' => [ + 'id' => "int", + 'url' => "str", + 'title' => "str", + ], + 'rows' => [ + [1,"http://example.com/1", "Feed 1"], + [2,"http://example.com/2", "Feed 2"], + [3,"http://example.com/3", "Feed 3"], + [4,"http://example.com/4", "Feed 4"], + [5,"http://example.com/5", "Feed 5"], + [6,"http://example.com/6", "Feed 6"], + [7,"http://example.com/7", "Feed 7"], + [8,"http://example.com/8", "Feed 8"], + [9,"http://example.com/9", "Feed 9"], + [10,"http://example.com/10", "Feed 10"], + [11,"http://example.com/11", "Feed 11"], + [12,"http://example.com/12", "Feed 12"], + [13,"http://example.com/13", "Feed 13"], + ] + ], + 'arsse_subscriptions' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'feed' => "int", + 'folder' => "int", + ], + 'rows' => [ + [1, "john.doe@example.com",1, null], + [2, "john.doe@example.com",2, null], + [3, "john.doe@example.com",3, 1], + [4, "john.doe@example.com",4, 6], + [5, "john.doe@example.com",5, 5], + [6, "john.doe@example.com",10, 5], + [7, "jane.doe@example.com",1, null], + [8, "jane.doe@example.com",10,null], + [9, "jane.doe@example.com",2, 4], + [10,"jane.doe@example.com",3, 4], + [11,"jane.doe@example.com",4, 4], + ] + ], ]; } @@ -119,8 +162,8 @@ trait SeriesFolder { public function testListRootFolders() { $exp = [ - ['id' => 5, 'name' => "Politics", 'parent' => null, 'children' => 0], - ['id' => 1, 'name' => "Technology", 'parent' => null, 'children' => 2], + ['id' => 5, 'name' => "Politics", 'parent' => null, 'children' => 0, 'feeds' => 2], + ['id' => 1, 'name' => "Technology", 'parent' => null, 'children' => 2, 'feeds' => 1], ]; $this->assertResult($exp, Arsse::$db->folderList("john.doe@example.com", null, false)); $exp = [ @@ -136,17 +179,17 @@ trait SeriesFolder { public function testListFoldersRecursively() { $exp = [ - ['id' => 5, 'name' => "Politics", 'parent' => null, 'children' => 0], - ['id' => 6, 'name' => "Politics", 'parent' => 2, 'children' => 0], - ['id' => 3, 'name' => "Rocketry", 'parent' => 1, 'children' => 0], - ['id' => 2, 'name' => "Software", 'parent' => 1, 'children' => 1], - ['id' => 1, 'name' => "Technology", 'parent' => null, 'children' => 2], + ['id' => 5, 'name' => "Politics", 'parent' => null, 'children' => 0, 'feeds' => 2], + ['id' => 6, 'name' => "Politics", 'parent' => 2, 'children' => 0, 'feeds' => 1], + ['id' => 3, 'name' => "Rocketry", 'parent' => 1, 'children' => 0, 'feeds' => 0], + ['id' => 2, 'name' => "Software", 'parent' => 1, 'children' => 1, 'feeds' => 0], + ['id' => 1, 'name' => "Technology", 'parent' => null, 'children' => 2, 'feeds' => 1], ]; $this->assertResult($exp, Arsse::$db->folderList("john.doe@example.com", null, true)); $exp = [ - ['id' => 6, 'name' => "Politics", 'parent' => 2, 'children' => 0], - ['id' => 3, 'name' => "Rocketry", 'parent' => 1, 'children' => 0], - ['id' => 2, 'name' => "Software", 'parent' => 1, 'children' => 1], + ['id' => 6, 'name' => "Politics", 'parent' => 2, 'children' => 0, 'feeds' => 1], + ['id' => 3, 'name' => "Rocketry", 'parent' => 1, 'children' => 0, 'feeds' => 0], + ['id' => 2, 'name' => "Software", 'parent' => 1, 'children' => 1, 'feeds' => 0], ]; $this->assertResult($exp, Arsse::$db->folderList("john.doe@example.com", 1, true)); $exp = []; From e2cba68c1b05c0e4db50ecc4bd115dfc6748811e Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 5 Mar 2019 19:22:01 -0500 Subject: [PATCH 035/142] Clarify various SQL queries --- lib/Database.php | 180 ++++++++++++-------------- tests/cases/Database/SeriesFolder.php | 63 +++++++-- 2 files changed, 139 insertions(+), 104 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 7c47ad3..3f552f4 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -428,14 +428,18 @@ class Database { $parent = $this->folderValidateId($user, $parent)['id']; $q = new Query( "SELECT - id,name,parent, - (select count(*) from arsse_folders as parents where coalesce(parents.parent,0) = coalesce(arsse_folders.id,0)) as children, - (select count(*) from arsse_subscriptions where coalesce(folder,0) = coalesce(arsse_folders.id,0)) as feeds - FROM arsse_folders" + id, + name, + arsse_folders.parent as parent, + coalesce(children,0) as children, + coalesce(feeds,0) as feeds + FROM arsse_folders + left join (SELECT parent,count(id) as children from arsse_folders group by parent) as child_stats on child_stats.parent = arsse_folders.id + left join (SELECT folder,count(id) as feeds from arsse_subscriptions group by folder) as sub_stats on sub_stats.folder = arsse_folders.id" ); if (!$recursive) { $q->setWhere("owner = ?", "str", $user); - $q->setWhere("coalesce(parent,0) = ?", "strict int", $parent); + $q->setWhere("coalesce(arsse_folders.parent,0) = ?", "strict int", $parent); } else { $q->setCTE("folders", "SELECT id from arsse_folders where owner = ? and coalesce(parent,0) = ? union select arsse_folders.id from arsse_folders join folders on arsse_folders.parent=folders.id", ["str", "strict int"], [$user, $parent]); $q->setWhere("id in (SELECT id from folders)"); @@ -687,22 +691,23 @@ class Database { $q = new Query( "SELECT arsse_subscriptions.id as id, - feed,url,favicon,source,folder,pinned,err_count,err_msg,order_type,added, + arsse_subscriptions.feed, + url,favicon,source,folder,pinned,err_count,err_msg,order_type,added, arsse_feeds.updated as updated, topmost.top as top_folder, coalesce(arsse_subscriptions.title, arsse_feeds.title) as title, - (SELECT count(*) from arsse_articles where feed = arsse_subscriptions.feed) - (SELECT count(*) from arsse_marks where subscription = arsse_subscriptions.id and \"read\" = 1) as unread - from arsse_subscriptions - join userdata on userid = owner - join arsse_feeds on feed = arsse_feeds.id - left join topmost on folder=f_id" + (articles - marked) as unread + FROM arsse_subscriptions + left join topmost on topmost.f_id = arsse_subscriptions.folder + join arsse_feeds on arsse_feeds.id = arsse_subscriptions.feed + left join (select feed, count(*) as articles from arsse_articles group by feed) as article_stats on article_stats.feed = arsse_subscriptions.feed + left join (select subscription, sum(\"read\") as marked from arsse_marks group by subscription) as mark_stats on mark_stats.subscription = arsse_subscriptions.id" ); + $q->setWhere("arsse_subscriptions.owner = ?", ["str"], [$user]); $nocase = $this->db->sqlToken("nocase"); $q->setOrder("pinned desc, coalesce(arsse_subscriptions.title, arsse_feeds.title) collate $nocase"); - // define common table expressions - $q->setCTE("userdata(userid)", "SELECT ?", "str", $user); // the subject user; this way we only have to pass it to prepare() once // topmost folders belonging to the user - $q->setCTE("topmost(f_id,top)", "SELECT id,id from arsse_folders join userdata on owner = userid where parent is null union select id,top from arsse_folders join topmost on parent=f_id"); + $q->setCTE("topmost(f_id,top)", "SELECT id,id from arsse_folders where owner = ? and parent is null union select id,top from arsse_folders join topmost on parent=f_id", ["str"], [$user]); if ($id) { // this condition facilitates the implementation of subscriptionPropertiesGet, which would otherwise have to duplicate the complex query; it takes precedence over a specified folder // if an ID is specified, add a suitable WHERE condition and bindings @@ -801,7 +806,7 @@ class Database { * - "order_type": Whether articles should be sorted in reverse cronological order (2), chronological order (1), or the default (0) * * @param string $user The user whose subscription is to be modified - * @param integer|null $id the numeric identifier of the subscription to modfify + * @param integer $id the numeric identifier of the subscription to modfify * @param array $data An associative array of properties to modify; any keys not specified will be left unchanged */ public function subscriptionPropertiesSet(string $user, $id, array $data): bool { @@ -873,7 +878,7 @@ class Database { * Returns an associative array containing the id of the subscription and the id of the underlying newsfeed * * @param string $user The user who owns the subscription to be validated - * @param integer|null $id The identifier of the subscription to validate + * @param integer $id The identifier of the subscription to validate * @param boolean $subject Whether the subscription is the subject (true) rather than the object (false) of the operation being performed; this only affects the semantics of the error message if validation fails */ protected function subscriptionValidateId(string $user, $id, bool $subject = false): array { @@ -1056,9 +1061,9 @@ class Database { public function feedCleanup(): bool { $tr = $this->begin(); // first unmark any feeds which are no longer orphaned - $this->db->query("UPDATE arsse_feeds set orphaned = null where exists(SELECT id from arsse_subscriptions where feed = arsse_feeds.id)"); + $this->db->query("WITH active_feeds as (select id from arsse_feeds left join (select feed, count(id) as count from arsse_subscriptions group by feed) as sub_stats on sub_stats.feed = arsse_feeds.id where orphaned is not null and count is not null) UPDATE arsse_feeds set orphaned = null where id in (select id from active_feeds)"); // next mark any newly orphaned feeds with the current date and time - $this->db->query("UPDATE arsse_feeds set orphaned = CURRENT_TIMESTAMP where orphaned is null and not exists(SELECT id from arsse_subscriptions where feed = arsse_feeds.id)"); + $this->db->query("WITH orphaned_feeds as (select id from arsse_feeds left join (select feed, count(id) as count from arsse_subscriptions group by feed) as sub_stats on sub_stats.feed = arsse_feeds.id where orphaned is null and count is null) UPDATE arsse_feeds set orphaned = CURRENT_TIMESTAMP where id in (select id from orphaned_feeds)"); // finally delete feeds that have been orphaned longer than the retention period, if a a purge threshold has been specified if (Arsse::$conf->purgeFeeds) { $limit = Date::sub(Arsse::$conf->purgeFeeds); @@ -1500,7 +1505,7 @@ class Database { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } $id = $this->articleValidateId($user, $id)['article']; - $out = $this->db->prepare("SELECT id,name from arsse_labels where owner = ? and exists(select id from arsse_label_members where article = ? and label = arsse_labels.id and assigned = 1)", "str", "int")->run($user, $id)->getAll(); + $out = $this->db->prepare("SELECT id, name from arsse_labels join arsse_label_members on arsse_label_members.label = arsse_labels.id where owner = ? and article = ? and assigned = 1", "str", "int")->run($user, $id)->getAll(); // flatten the result to return just the label ID or name, sorted $out = $out ? array_column($out, !$byName ? "id" : "name") : []; sort($out); @@ -1525,30 +1530,16 @@ class Database { /** Deletes from the database articles which are beyond the configured clean-up threshold */ public function articleCleanup(): bool { $query = $this->db->prepare( - "WITH target_feed(id,subs) as (". - "SELECT - id, (select count(*) from arsse_subscriptions where feed = arsse_feeds.id) as subs - from arsse_feeds where id = ?". - "), latest_editions(article,edition) as (". - "SELECT article,max(id) from arsse_editions group by article". - "), excepted_articles(id,edition) as (". - "SELECT - arsse_articles.id as id, - latest_editions.edition as edition - from arsse_articles - join target_feed on arsse_articles.feed = target_feed.id - join latest_editions on arsse_articles.id = latest_editions.article - order by edition desc limit ?". - ") ". - "DELETE from arsse_articles where - feed = (select max(id) from target_feed) - and id not in (select id from excepted_articles) - and (select count(*) from arsse_marks where article = arsse_articles.id and starred = 1) = 0 - and ( - coalesce((select max(modified) from arsse_marks where article = arsse_articles.id),modified) <= ? - or ((select max(subs) from target_feed) = (select count(*) from arsse_marks where article = arsse_articles.id and \"read\" = 1) and coalesce((select max(modified) from arsse_marks where article = arsse_articles.id),modified) <= ?) + "WITH RECURSIVE + exempt_articles as (SELECT id from arsse_articles join (SELECT article, max(id) as edition from arsse_editions group by article) as latest_editions on arsse_articles.id = latest_editions.article where feed = ? order by edition desc limit ?), + target_articles as ( + select id from arsse_articles + left join (select article, sum(starred) as starred, sum(\"read\") as \"read\", max(arsse_marks.modified) as marked_date from arsse_marks join arsse_subscriptions on arsse_subscriptions.id = arsse_marks.subscription group by article) as mark_stats on mark_stats.article = arsse_articles.id + left join (select feed, count(*) as subs from arsse_subscriptions group by feed) as feed_stats on feed_stats.feed = arsse_articles.feed + where arsse_articles.feed = ? and coalesce(starred,0) = 0 and (coalesce(marked_date,modified) <= ? or (coalesce(\"read\",0) = coalesce(subs,0) and coalesce(marked_date,modified) <= ?)) ) - ", + DELETE FROM arsse_articles WHERE id not in (select id from exempt_articles) and id in (select id from target_articles)", + "int", "int", "int", "datetime", @@ -1564,7 +1555,7 @@ class Database { } $feeds = $this->db->query("SELECT id, size from arsse_feeds")->getAll(); foreach ($feeds as $feed) { - $query->run($feed['id'], $feed['size'], $limitUnread, $limitRead); + $query->run($feed['id'], $feed['size'], $feed['id'], $limitUnread, $limitRead); } return true; } @@ -1574,21 +1565,19 @@ class Database { * Returns an associative array containing the id and latest edition of the article if it exists * * @param string $user The user who owns the article to be validated - * @param integer|null $id The identifier of the article to validate + * @param integer $id The identifier of the article to validate */ protected function articleValidateId(string $user, $id): array { if (!ValueInfo::id($id)) { throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "article", 'type' => "int > 0"]); // @codeCoverageIgnore } $out = $this->db->prepare( - "SELECT - arsse_articles.id as article, - (select max(id) from arsse_editions where article = arsse_articles.id) as edition - FROM arsse_articles - join arsse_feeds on arsse_feeds.id = arsse_articles.feed - join arsse_subscriptions on arsse_subscriptions.feed = arsse_feeds.id - WHERE - arsse_articles.id = ? and arsse_subscriptions.owner = ?", + "SELECT articles.article as article, max(arsse_editions.id) as edition from ( + select arsse_articles.id as article + FROM arsse_articles + join arsse_subscriptions on arsse_subscriptions.feed = arsse_articles.feed + WHERE arsse_articles.id = ? and arsse_subscriptions.owner = ? + ) as articles join arsse_editions on arsse_editions.article = articles.article group by articles.article", "int", "str" )->run($id, $user)->getRow(); @@ -1603,7 +1592,7 @@ class Database { * Returns an associative array containing the edition id, article id, and latest edition of the edition if it exists * * @param string $user The user who owns the edition to be validated - * @param integer|null $id The identifier of the edition to validate + * @param integer $id The identifier of the edition to validate */ protected function articleValidateEdition(string $user, int $id): array { if (!ValueInfo::id($id)) { @@ -1611,15 +1600,12 @@ class Database { } $out = $this->db->prepare( "SELECT - arsse_editions.id as edition, - arsse_editions.article as article, - (arsse_editions.id = (select max(id) from arsse_editions where article = arsse_editions.article)) as current - FROM arsse_editions - join arsse_articles on arsse_editions.article = arsse_articles.id - join arsse_feeds on arsse_feeds.id = arsse_articles.feed - join arsse_subscriptions on arsse_subscriptions.feed = arsse_feeds.id - WHERE - arsse_editions.id = ? and arsse_subscriptions.owner = ?", + arsse_editions.id, arsse_editions.article, edition_stats.edition as current + from arsse_editions + join arsse_articles on arsse_articles.id = arsse_editions.article + join arsse_subscriptions on arsse_subscriptions.feed = arsse_articles.feed + join (select article, max(id) as edition from arsse_editions group by article) as edition_stats on edition_stats.article = arsse_editions.article + where arsse_editions.id = ? and arsse_subscriptions.owner = ?", "int", "str" )->run($id, $user)->getRow(); @@ -1693,18 +1679,28 @@ class Database { return $this->db->prepare( "SELECT * FROM ( SELECT - id,name, - (select count(*) from arsse_label_members where label = id and assigned = 1) as articles, - (select count(*) from arsse_label_members - join arsse_marks on arsse_label_members.article = arsse_marks.article and arsse_label_members.subscription = arsse_marks.subscription - where label = id and assigned = 1 and \"read\" = 1 - ) as \"read\" - FROM arsse_labels where owner = ?) as label_data + id,name,coalesce(articles,0) as articles,coalesce(marked,0) as \"read\" + from arsse_labels + left join ( + SELECT label, sum(assigned) as articles from arsse_label_members group by label + ) as label_stats on label_stats.label = arsse_labels.id + left join ( + SELECT + label, sum(\"read\") as marked + from arsse_marks + join arsse_subscriptions on arsse_subscriptions.id = arsse_marks.subscription + join arsse_label_members on arsse_label_members.article = arsse_marks.article + where arsse_subscriptions.owner = ? + group by label + ) as mark_stats on mark_stats.label = arsse_labels.id + WHERE owner = ? + ) as label_data where articles >= ? order by name ", "str", + "str", "int" - )->run($user, !$includeEmpty); + )->run($user, $user, !$includeEmpty); } /** Deletes a label from the database @@ -1751,17 +1747,26 @@ class Database { $type = $byName ? "str" : "int"; $out = $this->db->prepare( "SELECT - id,name, - (select count(*) from arsse_label_members where label = id and assigned = 1) as articles, - (select count(*) from arsse_label_members - join arsse_marks on arsse_label_members.article = arsse_marks.article and arsse_label_members.subscription = arsse_marks.subscription - where label = id and assigned = 1 and \"read\" = 1 - ) as \"read\" - FROM arsse_labels where $field = ? and owner = ? + id,name,coalesce(articles,0) as articles,coalesce(marked,0) as \"read\" + FROM arsse_labels + left join ( + SELECT label, sum(assigned) as articles from arsse_label_members group by label + ) as label_stats on label_stats.label = arsse_labels.id + left join ( + SELECT + label, sum(\"read\") as marked + from arsse_marks + join arsse_subscriptions on arsse_subscriptions.id = arsse_marks.subscription + join arsse_label_members on arsse_label_members.article = arsse_marks.article + where arsse_subscriptions.owner = ? + group by label + ) as mark_stats on mark_stats.label = arsse_labels.id + WHERE $field = ? and owner = ? ", + "str", $type, "str" - )->run($id, $user)->getRow(); + )->run($user, $id, $user)->getRow(); if (!$out) { throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "label", 'id' => $id]); } @@ -1846,27 +1851,14 @@ class Database { $tr = $this->begin(); // first update any existing entries with the removal or re-addition of their association $q = $this->articleQuery($user, $context); - $q->setWhere("exists(select article from arsse_label_members where label = ? and article = arsse_articles.id)", "int", $id); $q->pushCTE("target_articles"); - $q->setBody( - "UPDATE arsse_label_members set assigned = ?, modified = CURRENT_TIMESTAMP where label = ? and assigned <> ? and article in (select id from target_articles)", - ["bool","int","bool"], - [!$remove, $id, !$remove] - ); + $q->setBody("UPDATE arsse_label_members set assigned = ?, modified = CURRENT_TIMESTAMP where label = ? and assigned <> ? and article in (select id from target_articles)", ["bool","int","bool"], [!$remove, $id, !$remove]); $out += $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes(); // next, if we're not removing, add any new entries that need to be added if (!$remove) { - $q = $this->articleQuery($user, $context, ["id", "feed"]); - $q->setWhere("not exists(select article from arsse_label_members where label = ? and article = arsse_articles.id)", "int", $id); + $q = $this->articleQuery($user, $context, ["id", "subscription"]); $q->pushCTE("target_articles"); - $q->setBody( - "SELECT - ?,id, - (select id from arsse_subscriptions where owner = ? and arsse_subscriptions.feed = target_articles.feed) - FROM target_articles", - ["int", "str"], - [$id, $user] - ); + $q->setBody("SELECT ?,id,subscription from target_articles where id not in (select article from arsse_label_members where label = ?)", ["int", "int"], [$id, $id]); $out += $this->db->prepare("INSERT INTO arsse_label_members(label,article,subscription) ".$q->getQuery(), $q->getTypes())->run($q->getValues())->changes(); } // commit the transaction diff --git a/tests/cases/Database/SeriesFolder.php b/tests/cases/Database/SeriesFolder.php index 7265f07..99c9f1a 100644 --- a/tests/cases/Database/SeriesFolder.php +++ b/tests/cases/Database/SeriesFolder.php @@ -49,6 +49,49 @@ trait SeriesFolder { [6, "john.doe@example.com", 2, "Politics"], ] ], + 'arsse_feeds' => [ + 'columns' => [ + 'id' => "int", + 'url' => "str", + 'title' => "str", + ], + 'rows' => [ + [1,"http://example.com/1", "Feed 1"], + [2,"http://example.com/2", "Feed 2"], + [3,"http://example.com/3", "Feed 3"], + [4,"http://example.com/4", "Feed 4"], + [5,"http://example.com/5", "Feed 5"], + [6,"http://example.com/6", "Feed 6"], + [7,"http://example.com/7", "Feed 7"], + [8,"http://example.com/8", "Feed 8"], + [9,"http://example.com/9", "Feed 9"], + [10,"http://example.com/10", "Feed 10"], + [11,"http://example.com/11", "Feed 11"], + [12,"http://example.com/12", "Feed 12"], + [13,"http://example.com/13", "Feed 13"], + ] + ], + 'arsse_subscriptions' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'feed' => "int", + 'folder' => "int", + ], + 'rows' => [ + [1, "john.doe@example.com",1, null], + [2, "john.doe@example.com",2, null], + [3, "john.doe@example.com",3, 1], + [4, "john.doe@example.com",4, 6], + [5, "john.doe@example.com",5, 5], + [6, "john.doe@example.com",10, 5], + [7, "jane.doe@example.com",1, null], + [8, "jane.doe@example.com",10,null], + [9, "jane.doe@example.com",2, 4], + [10,"jane.doe@example.com",3, 4], + [11,"jane.doe@example.com",4, 4], + ] + ], ]; } @@ -119,8 +162,8 @@ trait SeriesFolder { public function testListRootFolders() { $exp = [ - ['id' => 5, 'name' => "Politics", 'parent' => null, 'children' => 0], - ['id' => 1, 'name' => "Technology", 'parent' => null, 'children' => 2], + ['id' => 5, 'name' => "Politics", 'parent' => null, 'children' => 0, 'feeds' => 2], + ['id' => 1, 'name' => "Technology", 'parent' => null, 'children' => 2, 'feeds' => 1], ]; $this->assertResult($exp, Arsse::$db->folderList("john.doe@example.com", null, false)); $exp = [ @@ -136,17 +179,17 @@ trait SeriesFolder { public function testListFoldersRecursively() { $exp = [ - ['id' => 5, 'name' => "Politics", 'parent' => null, 'children' => 0], - ['id' => 6, 'name' => "Politics", 'parent' => 2, 'children' => 0], - ['id' => 3, 'name' => "Rocketry", 'parent' => 1, 'children' => 0], - ['id' => 2, 'name' => "Software", 'parent' => 1, 'children' => 1], - ['id' => 1, 'name' => "Technology", 'parent' => null, 'children' => 2], + ['id' => 5, 'name' => "Politics", 'parent' => null, 'children' => 0, 'feeds' => 2], + ['id' => 6, 'name' => "Politics", 'parent' => 2, 'children' => 0, 'feeds' => 1], + ['id' => 3, 'name' => "Rocketry", 'parent' => 1, 'children' => 0, 'feeds' => 0], + ['id' => 2, 'name' => "Software", 'parent' => 1, 'children' => 1, 'feeds' => 0], + ['id' => 1, 'name' => "Technology", 'parent' => null, 'children' => 2, 'feeds' => 1], ]; $this->assertResult($exp, Arsse::$db->folderList("john.doe@example.com", null, true)); $exp = [ - ['id' => 6, 'name' => "Politics", 'parent' => 2, 'children' => 0], - ['id' => 3, 'name' => "Rocketry", 'parent' => 1, 'children' => 0], - ['id' => 2, 'name' => "Software", 'parent' => 1, 'children' => 1], + ['id' => 6, 'name' => "Politics", 'parent' => 2, 'children' => 0, 'feeds' => 1], + ['id' => 3, 'name' => "Rocketry", 'parent' => 1, 'children' => 0, 'feeds' => 0], + ['id' => 2, 'name' => "Software", 'parent' => 1, 'children' => 1, 'feeds' => 0], ]; $this->assertResult($exp, Arsse::$db->folderList("john.doe@example.com", 1, true)); $exp = []; From ff0c9a3a55f76b28b4328cb80b6c49fa45122338 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 6 Mar 2019 22:15:41 -0500 Subject: [PATCH 036/142] Add functionality for interacting with subscription tags --- lib/Database.php | 348 ++++++++++++++++- lib/Db/SQLite3/ExceptionBuilder.php | 3 +- tests/cases/Database/Base.php | 3 +- tests/cases/Database/SeriesSubscription.php | 45 +++ tests/cases/Database/SeriesTag.php | 395 ++++++++++++++++++++ 5 files changed, 775 insertions(+), 19 deletions(-) create mode 100644 tests/cases/Database/SeriesTag.php diff --git a/lib/Database.php b/lib/Database.php index a5c7781..043b993 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -21,6 +21,7 @@ use JKingWeb\Arsse\Misc\ValueInfo; * - Users * - Subscriptions to feeds, which belong to users * - Folders, which belong to users and contain subscriptions + * - Tags, which belong to users and can be assigned to multiple subscriptions * - Feeds to which users are subscribed * - Articles, which belong to feeds and for which users can only affect metadata * - Editions, identifying authorial modifications to articles @@ -849,6 +850,22 @@ class Database { return $out; } + /** Returns an indexed array listing the tags assigned to a subscription + * + * @param string $user The user whose tags are to be listed + * @param integer $id The numeric identifier of the subscription whose tags are to be listed + * @param boolean $byName Whether to return the tag names (true) instead of the numeric tag identifiers (false) + */ + public function subscriptionTagsGet(string $user, $id, bool $byName = false): array { + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + $this->subscriptionValidateId($user, $id, true); + $field = !$byName ? "id" : "name"; + $out = $this->db->prepare("SELECT $field from arsse_tags where id in (select tag from arsse_tag_members where subscription = ? and assigned = 1) order by $field", "int")->run($id)->getAll(); + return $out ? array_column($out, $field) : []; + } + /** Retrieves the URL of the icon for a subscription. * * Note that while the $user parameter is optional, it @@ -1505,11 +1522,9 @@ class Database { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } $id = $this->articleValidateId($user, $id)['article']; - $out = $this->db->prepare("SELECT id, name from arsse_labels join arsse_label_members on arsse_label_members.label = arsse_labels.id where owner = ? and article = ? and assigned = 1", "str", "int")->run($user, $id)->getAll(); - // flatten the result to return just the label ID or name, sorted - $out = $out ? array_column($out, !$byName ? "id" : "name") : []; - sort($out); - return $out; + $field = !$byName ? "id" : "name"; + $out = $this->db->prepare("SELECT $field from arsse_labels join arsse_label_members on arsse_label_members.label = arsse_labels.id where owner = ? and article = ? and assigned = 1 order by $field", "str", "int")->run($user, $id)->getAll(); + return $out ? array_column($out, $field) : []; } /** Returns the author-supplied categories associated with an article */ @@ -1846,22 +1861,28 @@ class Database { // validate the label ID, and get the numeric ID if matching by name $id = $this->labelValidateId($user, $id, $byName, true)['id']; $context = $context ?? new Context; - $out = 0; - // wrap this UPDATE and INSERT together into a transaction - $tr = $this->begin(); + // prepare either one or two queries // first update any existing entries with the removal or re-addition of their association - $q = $this->articleQuery($user, $context); - $q->pushCTE("target_articles"); - $q->setBody("UPDATE arsse_label_members set assigned = ?, modified = CURRENT_TIMESTAMP where label = ? and assigned <> ? and article in (select id from target_articles)", ["bool","int","bool"], [!$remove, $id, !$remove]); - $out += $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes(); + $q1 = $this->articleQuery($user, $context); + $q1->pushCTE("target_articles"); + $q1->setBody("UPDATE arsse_label_members set assigned = ?, modified = CURRENT_TIMESTAMP where label = ? and assigned <> ? and article in (select id from target_articles)", ["bool","int","bool"], [!$remove, $id, !$remove]); + $v1 = $q1->getValues(); + $q1 = $this->db->prepare($q1->getQuery(), $q1->getTypes()); // next, if we're not removing, add any new entries that need to be added if (!$remove) { - $q = $this->articleQuery($user, $context, ["id", "subscription"]); - $q->pushCTE("target_articles"); - $q->setBody("SELECT ?,id,subscription from target_articles where id not in (select article from arsse_label_members where label = ?)", ["int", "int"], [$id, $id]); - $out += $this->db->prepare("INSERT INTO arsse_label_members(label,article,subscription) ".$q->getQuery(), $q->getTypes())->run($q->getValues())->changes(); + $q2 = $this->articleQuery($user, $context, ["id", "subscription"]); + $q2->pushCTE("target_articles"); + $q2->setBody("SELECT ?,id,subscription from target_articles where id not in (select article from arsse_label_members where label = ?)", ["int", "int"], [$id, $id]); + $v2 = $q2->getValues(); + $q2 = $this->db->prepare("INSERT INTO arsse_label_members(label,article,subscription) ".$q2->getQuery(), $q2->getTypes()); + } + // execute them in a transaction + $out = 0; + $tr = $this->begin(); + $out += $q1->run($v1)->changes(); + if (!$remove) { + $out += $q2->run($v2)->changes(); } - // commit the transaction $tr->commit(); return $out; } @@ -1912,4 +1933,297 @@ class Database { return true; } } + + /** Creates a tag, and returns its numeric identifier + * + * Tags are discrete objects in the database and can be associated with multiple subscriptions; a subscription may in turn be associated with multiple tags + * + * @param string $user The user who will own the created tag + * @param array $data An associative array defining the tag's properties; currently only "name" is understood + */ + public function tagAdd(string $user, array $data): int { + // if the user isn't authorized to perform this action then throw an exception. + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + // validate the tag name + $name = array_key_exists("name", $data) ? $data['name'] : ""; + $this->tagValidateName($name, true); + // perform the insert + return $this->db->prepare("INSERT INTO arsse_tags(owner,name) values(?,?)", "str", "str")->run($user, $name)->lastId(); + } + + /** Lists a user's subscription tags + * + * The following keys are included in each record: + * + * - "id": The tag's numeric identifier + * - "name" The tag's textual name + * - "subscriptions": The count of subscriptions which have the tag assigned to them + * + * @param string $user The user whose tags are to be listed + * @param boolean $includeEmpty Whether to include (true) or supress (false) tags which have no subscriptions assigned to them + */ + public function tagList(string $user, bool $includeEmpty = true): Db\Result { + // if the user isn't authorized to perform this action then throw an exception. + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + return $this->db->prepare( + "SELECT * FROM ( + SELECT + id,name,coalesce(subscriptions,0) as subscriptions + from arsse_tags + left join (SELECT tag, sum(assigned) as subscriptions from arsse_tag_members group by tag) as tag_stats on tag_stats.tag = arsse_tags.id + WHERE owner = ? + ) as tag_data + where subscriptions >= ? order by name + ", + "str", + "int" + )->run($user, !$includeEmpty); + } + + /** Lists the associations between all tags and subscription + * + * The following keys are included in each record: + * + * - "tag_id": The tag's numeric identifier + * - "tag_name" The tag's textual name + * - "subscription_id": The numeric identifier of the associated subscription + * - "subscription_name" The subscription's textual name + * + * @param string $user The user whose tags are to be listed + */ + public function tagSummarize(string $user): Db\Result { + // if the user isn't authorized to perform this action then throw an exception. + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + return $this->db->prepare( + "SELECT + arsse_tags.id as tag_id, + arsse_tags.name as tag_name, + arsse_subscriptions.id as subscription_id, + coalesce(arsse_subscriptions.title, arsse_feeds.title) as subscription_name + FROM arsse_tag_members + join arsse_tags on arsse_tags.id = arsse_tag_members.tag + join arsse_subscriptions on arsse_subscriptions.id = arsse_tag_members.subscription + join arsse_feeds on arsse_feeds.id = arsse_subscriptions.feed + WHERE arsse_tags.owner = ? and assigned = 1", + "str" + )->run($user); + } + + /** Deletes a tag from the database + * + * Any subscriptions associated with the tag remains untouched + * + * @param string $user The owner of the tag to remove + * @param integer|string $id The numeric identifier or name of the tag + * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) + */ + public function tagRemove(string $user, $id, bool $byName = false): bool { + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + $this->tagValidateId($user, $id, $byName, false); + $field = $byName ? "name" : "id"; + $type = $byName ? "str" : "int"; + $changes = $this->db->prepare("DELETE FROM arsse_tags where owner = ? and $field = ?", "str", $type)->run($user, $id)->changes(); + if (!$changes) { + throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "tag", 'id' => $id]); + } + return true; + } + + /** Retrieves the properties of a tag + * + * The following keys are included in the output array: + * + * - "id": The tag's numeric identifier + * - "name" The tag's textual name + * - "subscriptions": The count of subscriptions which have the tag assigned to them + * + * @param string $user The owner of the tag to remove + * @param integer|string $id The numeric identifier or name of the tag + * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) + */ + public function tagPropertiesGet(string $user, $id, bool $byName = false): array { + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + $this->tagValidateId($user, $id, $byName, false); + $field = $byName ? "name" : "id"; + $type = $byName ? "str" : "int"; + $out = $this->db->prepare( + "SELECT + id,name,coalesce(subscriptions,0) as subscriptions + FROM arsse_tags + left join (SELECT tag, sum(assigned) as subscriptions from arsse_tag_members group by tag) as tag_stats on tag_stats.tag = arsse_tags.id + WHERE $field = ? and owner = ? + ", + $type, + "str" + )->run($id, $user)->getRow(); + if (!$out) { + throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "tag", 'id' => $id]); + } + return $out; + } + + /** Sets the properties of a tag + * + * @param string $user The owner of the tag to query + * @param integer|string $id The numeric identifier or name of the tag + * @param array $data An associative array defining the tag's properties; currently only "name" is understood + * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) + */ + public function tagPropertiesSet(string $user, $id, array $data, bool $byName = false): bool { + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + $this->tagValidateId($user, $id, $byName, false); + if (isset($data['name'])) { + $this->tagValidateName($data['name']); + } + $field = $byName ? "name" : "id"; + $type = $byName ? "str" : "int"; + $valid = [ + 'name' => "str", + ]; + list($setClause, $setTypes, $setValues) = $this->generateSet($data, $valid); + if (!$setClause) { + // if no changes would actually be applied, just return + return false; + } + $out = (bool) $this->db->prepare("UPDATE arsse_tags set $setClause, modified = CURRENT_TIMESTAMP where owner = ? and $field = ?", $setTypes, "str", $type)->run($setValues, $user, $id)->changes(); + if (!$out) { + throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "tag", 'id' => $id]); + } + return $out; + } + + /** Returns an indexed array of subscription identifiers assigned to a tag + * + * @param string $user The owner of the tag to query + * @param integer|string $id The numeric identifier or name of the tag + * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) + */ + public function tagSubscriptionsGet(string $user, $id, bool $byName = false): array { + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + // just do a syntactic check on the tag ID + $this->tagValidateId($user, $id, $byName, false); + $field = !$byName ? "id" : "name"; + $type = !$byName ? "int" : "str"; + $out = $this->db->prepare("SELECT subscription from arsse_tag_members join arsse_tags on tag = id where assigned = 1 and $field = ? and owner = ? order by subscription", $type, "str")->run($id, $user)->getAll(); + if (!$out) { + // if no results were returned, do a full validation on the tag ID + $this->tagValidateId($user, $id, $byName, true, true); + // if the validation passes, return the empty result + return $out; + } else { + // flatten the result to return just the subscription IDs in a simple array + return array_column($out, "subscription"); + } + } + + /** Makes or breaks associations between a given tag and specified subscriptions + * + * @param string $user The owner of the tag + * @param integer|string $id The numeric identifier or name of the tag + * @param integer[] $context The query context matching the desired subscriptions + * @param boolean $remove Whether to remove (true) rather than add (true) an association with the subscriptions matching the context + * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) + */ + public function tagSubscriptionsSet(string $user, $id, array $subscriptions, bool $remove = false, bool $byName = false): int { + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + // validate the tag ID, and get the numeric ID if matching by name + $id = $this->tagValidateId($user, $id, $byName, true)['id']; + // prepare either one or two queries + list($inClause, $inTypes, $inValues) = $this->generateIn($subscriptions, "int"); + // first update any existing entries with the removal or re-addition of their association + $q1 = $this->db->prepare( + "UPDATE arsse_tag_members + set assigned = ?, modified = CURRENT_TIMESTAMP + where tag = ? and assigned <> ? and subscription in (select id from arsse_subscriptions where owner = ? and id in ($inClause))", + "bool", + "int", + "bool", + "str", + $inTypes + ); + $v1 = [!$remove, $id, !$remove, $user, $inValues]; + // next, if we're not removing, add any new entries that need to be added + if (!$remove) { + $q2 = $this->db->prepare( + "INSERT INTO arsse_tag_members(tag,subscription) SELECT ?,id from arsse_subscriptions where id not in (select subscription from arsse_tag_members where tag = ?) and owner = ? and id in ($inClause)", + "int", + "int", + "str", + $inTypes + ); + $v2 = [$id, $id, $user, $inValues]; + } + // execute them in a transaction + $out = 0; + $tr = $this->begin(); + $out += $q1->run($v1)->changes(); + if (!$remove) { + $out += $q2->run($v2)->changes(); + } + $tr->commit(); + return $out; + } + + /** Ensures the specified tag identifier or name is valid (and optionally whether it exists) and raises an exception otherwise + * + * Returns an associative array containing the id, name of the tag if it exists + * + * @param string $user The user who owns the tag to be validated + * @param integer|string $id The numeric identifier or name of the tag to validate + * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) + * @param boolean $checkDb Whether to check whether the tag exists (true) or only if the identifier or name is syntactically valid (false) + * @param boolean $subject Whether the tag is the subject (true) rather than the object (false) of the operation being performed; this only affects the semantics of the error message if validation fails + */ + protected function tagValidateId(string $user, $id, bool $byName, bool $checkDb = true, bool $subject = false): array { + if (!$byName && !ValueInfo::id($id)) { + // if we're not referring to a tag by name and the ID is invalid, throw an exception + throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "tag", 'type' => "int > 0"]); + } elseif ($byName && !(ValueInfo::str($id) & ValueInfo::VALID)) { + // otherwise if we are referring to a tag by name but the ID is not a string, also throw an exception + throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "tag", 'type' => "string"]); + } elseif ($checkDb) { + $field = !$byName ? "id" : "name"; + $type = !$byName ? "int" : "str"; + $l = $this->db->prepare("SELECT id,name from arsse_tags where $field = ? and owner = ?", $type, "str")->run($id, $user)->getRow(); + if (!$l) { + throw new Db\ExceptionInput($subject ? "subjectMissing" : "idMissing", ["action" => $this->caller(), "field" => "tag", 'id' => $id]); + } else { + return $l; + } + } + return [ + 'id' => !$byName ? $id : null, + 'name' => $byName ? $id : null, + ]; + } + + /** Ensures a prospective tag name is syntactically valid and raises an exception otherwise */ + protected function tagValidateName($name): bool { + $info = ValueInfo::str($name); + if ($info & (ValueInfo::NULL | ValueInfo::EMPTY)) { + throw new Db\ExceptionInput("missing", ["action" => $this->caller(), "field" => "name"]); + } elseif ($info & ValueInfo::WHITE) { + throw new Db\ExceptionInput("whitespace", ["action" => $this->caller(), "field" => "name"]); + } elseif (!($info & ValueInfo::VALID)) { + throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "name", 'type' => "string"]); + } else { + return true; + } + } } diff --git a/lib/Db/SQLite3/ExceptionBuilder.php b/lib/Db/SQLite3/ExceptionBuilder.php index c87e62f..22d1723 100644 --- a/lib/Db/SQLite3/ExceptionBuilder.php +++ b/lib/Db/SQLite3/ExceptionBuilder.php @@ -21,7 +21,8 @@ trait ExceptionBuilder { case Driver::SQLITE_BUSY: return [ExceptionTimeout::class, 'general', $msg]; case Driver::SQLITE_SCHEMA: - return [ExceptionRetry::class, 'schemaChange', $msg]; + // sometimes encountered with PDO, because PDO sucks + return [ExceptionRetry::class, 'schemaChange', $msg]; // @codeCoverageIgnore case Driver::SQLITE_CONSTRAINT: return [ExceptionInput::class, 'engineConstraintViolation', $msg]; case Driver::SQLITE_MISMATCH: diff --git a/tests/cases/Database/Base.php b/tests/cases/Database/Base.php index 219d4c0..9e140c4 100644 --- a/tests/cases/Database/Base.php +++ b/tests/cases/Database/Base.php @@ -23,8 +23,9 @@ abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest { use SeriesFolder; use SeriesFeed; use SeriesSubscription; - use SeriesArticle; use SeriesLabel; + use SeriesTag; + use SeriesArticle; use SeriesCleanup; /** @var \JKingWeb\Arsse\Db\Driver */ diff --git a/tests/cases/Database/SeriesSubscription.php b/tests/cases/Database/SeriesSubscription.php index f2811f1..0adac9e 100644 --- a/tests/cases/Database/SeriesSubscription.php +++ b/tests/cases/Database/SeriesSubscription.php @@ -69,6 +69,33 @@ trait SeriesSubscription { [3,"john.doe@example.com",3,"Ook",2,0,1], ] ], + 'arsse_tags' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'name' => "str", + ], + 'rows' => [ + [1,"john.doe@example.com","Interesting"], + [2,"john.doe@example.com","Fascinating"], + [3,"jane.doe@example.com","Boring"], + [4,"john.doe@example.com","Lonely"], + ], + ], + 'arsse_tag_members' => [ + 'columns' => [ + 'tag' => "int", + 'subscription' => "int", + 'assigned' => "bool", + ], + 'rows' => [ + [1,1,1], + [1,3,0], + [2,1,1], + [2,3,1], + [3,2,1], + ], + ], 'arsse_articles' => [ 'columns' => [ 'id' => "int", @@ -447,4 +474,22 @@ trait SeriesSubscription { $this->assertException("notAuthorized", "User", "ExceptionAuthz"); Arsse::$db->subscriptionFavicon(-2112, $user); } + + public function testListTheTagsOfASubscription() { + $this->assertEquals([1,2], Arsse::$db->subscriptionTagsGet("john.doe@example.com", 1)); + $this->assertEquals([2], Arsse::$db->subscriptionTagsGet("john.doe@example.com", 3)); + $this->assertEquals(["Fascinating","Interesting"], Arsse::$db->subscriptionTagsGet("john.doe@example.com", 1, true)); + $this->assertEquals(["Fascinating"], Arsse::$db->subscriptionTagsGet("john.doe@example.com", 3, true)); + } + + public function testListTheTagsOfAMissingSubscription() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->subscriptionTagsGet($this->user, 101); + } + + public function testListTheTagsOfASubscriptionWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->subscriptionTagsGet("john.doe@example.com", 1); + } } diff --git a/tests/cases/Database/SeriesTag.php b/tests/cases/Database/SeriesTag.php new file mode 100644 index 0000000..ea40d41 --- /dev/null +++ b/tests/cases/Database/SeriesTag.php @@ -0,0 +1,395 @@ +data = [ + 'arsse_users' => [ + 'columns' => [ + 'id' => 'str', + 'password' => 'str', + 'name' => 'str', + ], + 'rows' => [ + ["jane.doe@example.com", "", "Jane Doe"], + ["john.doe@example.com", "", "John Doe"], + ["john.doe@example.org", "", "John Doe"], + ["john.doe@example.net", "", "John Doe"], + ], + ], + 'arsse_feeds' => [ + 'columns' => [ + 'id' => "int", + 'url' => "str", + 'title' => "str", + ], + 'rows' => [ + [1,"http://example.com/1",""], + [2,"http://example.com/2",""], + [3,"http://example.com/3","Feed Title"], + [4,"http://example.com/4",""], + [5,"http://example.com/5","Feed Title"], + [6,"http://example.com/6",""], + [7,"http://example.com/7",""], + [8,"http://example.com/8",""], + [9,"http://example.com/9",""], + [10,"http://example.com/10",""], + [11,"http://example.com/11",""], + [12,"http://example.com/12",""], + [13,"http://example.com/13",""], + ] + ], + 'arsse_subscriptions' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'feed' => "int", + 'title' => "str", + ], + 'rows' => [ + [1, "john.doe@example.com", 1,"Lord of Carrots"], + [2, "john.doe@example.com", 2,null], + [3, "john.doe@example.com", 3,"Subscription Title"], + [4, "john.doe@example.com", 4,null], + [5, "john.doe@example.com",10,null], + [6, "jane.doe@example.com", 1,null], + [7, "jane.doe@example.com",10,null], + [8, "john.doe@example.org",11,null], + [9, "john.doe@example.org",12,null], + [10,"john.doe@example.org",13,null], + [11,"john.doe@example.net",10,null], + [12,"john.doe@example.net", 2,null], + [13,"john.doe@example.net", 3,null], + [14,"john.doe@example.net", 4,null], + ] + ], + 'arsse_tags' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'name' => "str", + ], + 'rows' => [ + [1,"john.doe@example.com","Interesting"], + [2,"john.doe@example.com","Fascinating"], + [3,"jane.doe@example.com","Boring"], + [4,"john.doe@example.com","Lonely"], + ], + ], + 'arsse_tag_members' => [ + 'columns' => [ + 'tag' => "int", + 'subscription' => "int", + 'assigned' => "bool", + ], + 'rows' => [ + [1,1,1], + [1,3,0], + [1,5,1], + [2,1,1], + [2,3,1], + [2,5,1], + ], + ], + ]; + $this->checkTags = ['arsse_tags' => ["id","owner","name"]]; + $this->checkMembers = ['arsse_tag_members' => ["tag","subscription","assigned"]]; + $this->user = "john.doe@example.com"; + } + + protected function tearDownSeriesTag() { + unset($this->data, $this->checkTags, $this->checkMembers, $this->user); + } + + public function testAddATag() { + $user = "john.doe@example.com"; + $tagID = $this->nextID("arsse_tags"); + $this->assertSame($tagID, Arsse::$db->tagAdd($user, ['name' => "Entertaining"])); + Phake::verify(Arsse::$user)->authorize($user, "tagAdd"); + $state = $this->primeExpectations($this->data, $this->checkTags); + $state['arsse_tags']['rows'][] = [$tagID, $user, "Entertaining"]; + $this->compareExpectations($state); + } + + public function testAddADuplicateTag() { + $this->assertException("constraintViolation", "Db", "ExceptionInput"); + Arsse::$db->tagAdd("john.doe@example.com", ['name' => "Interesting"]); + } + + public function testAddATagWithAMissingName() { + $this->assertException("missing", "Db", "ExceptionInput"); + Arsse::$db->tagAdd("john.doe@example.com", []); + } + + public function testAddATagWithABlankName() { + $this->assertException("missing", "Db", "ExceptionInput"); + Arsse::$db->tagAdd("john.doe@example.com", ['name' => ""]); + } + + public function testAddATagWithAWhitespaceName() { + $this->assertException("whitespace", "Db", "ExceptionInput"); + Arsse::$db->tagAdd("john.doe@example.com", ['name' => " "]); + } + + public function testAddATagWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->tagAdd("john.doe@example.com", ['name' => "Boring"]); + } + + public function testListTags() { + $exp = [ + ['id' => 2, 'name' => "Fascinating"], + ['id' => 1, 'name' => "Interesting"], + ['id' => 4, 'name' => "Lonely"], + ]; + $this->assertResult($exp, Arsse::$db->tagList("john.doe@example.com")); + $exp = [ + ['id' => 3, 'name' => "Boring"], + ]; + $this->assertResult($exp, Arsse::$db->tagList("jane.doe@example.com")); + $exp = []; + $this->assertResult($exp, Arsse::$db->tagList("jane.doe@example.com", false)); + Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "tagList"); + } + + public function testListTagsWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->tagList("john.doe@example.com"); + } + + public function testRemoveATag() { + $this->assertTrue(Arsse::$db->tagRemove("john.doe@example.com", 1)); + Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "tagRemove"); + $state = $this->primeExpectations($this->data, $this->checkTags); + array_shift($state['arsse_tags']['rows']); + $this->compareExpectations($state); + } + + public function testRemoveATagByName() { + $this->assertTrue(Arsse::$db->tagRemove("john.doe@example.com", "Interesting", true)); + Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "tagRemove"); + $state = $this->primeExpectations($this->data, $this->checkTags); + array_shift($state['arsse_tags']['rows']); + $this->compareExpectations($state); + } + + public function testRemoveAMissingTag() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->tagRemove("john.doe@example.com", 2112); + } + + public function testRemoveAnInvalidTag() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->tagRemove("john.doe@example.com", -1); + } + + public function testRemoveAnInvalidTagByName() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->tagRemove("john.doe@example.com", [], true); + } + + public function testRemoveATagOfTheWrongOwner() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->tagRemove("john.doe@example.com", 3); // tag ID 3 belongs to Jane + } + + public function testRemoveATagWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->tagRemove("john.doe@example.com", 1); + } + + public function testGetThePropertiesOfATag() { + $exp = [ + 'id' => 2, + 'name' => "Fascinating", + ]; + $this->assertArraySubset($exp, Arsse::$db->tagPropertiesGet("john.doe@example.com", 2)); + $this->assertArraySubset($exp, Arsse::$db->tagPropertiesGet("john.doe@example.com", "Fascinating", true)); + Phake::verify(Arsse::$user, Phake::times(2))->authorize("john.doe@example.com", "tagPropertiesGet"); + } + + public function testGetThePropertiesOfAMissingTag() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->tagPropertiesGet("john.doe@example.com", 2112); + } + + public function testGetThePropertiesOfAnInvalidTag() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->tagPropertiesGet("john.doe@example.com", -1); + } + + public function testGetThePropertiesOfAnInvalidTagByName() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->tagPropertiesGet("john.doe@example.com", [], true); + } + + public function testGetThePropertiesOfATagOfTheWrongOwner() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->tagPropertiesGet("john.doe@example.com", 3); // tag ID 3 belongs to Jane + } + + public function testGetThePropertiesOfATagWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->tagPropertiesGet("john.doe@example.com", 1); + } + + public function testMakeNoChangesToATag() { + $this->assertFalse(Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, [])); + } + + public function testRenameATag() { + $this->assertTrue(Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, ['name' => "Curious"])); + Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "tagPropertiesSet"); + $state = $this->primeExpectations($this->data, $this->checkTags); + $state['arsse_tags']['rows'][0][2] = "Curious"; + $this->compareExpectations($state); + } + + public function testRenameATagByName() { + $this->assertTrue(Arsse::$db->tagPropertiesSet("john.doe@example.com", "Interesting", ['name' => "Curious"], true)); + Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "tagPropertiesSet"); + $state = $this->primeExpectations($this->data, $this->checkTags); + $state['arsse_tags']['rows'][0][2] = "Curious"; + $this->compareExpectations($state); + } + + public function testRenameATagToTheEmptyString() { + $this->assertException("missing", "Db", "ExceptionInput"); + $this->assertTrue(Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, ['name' => ""])); + } + + public function testRenameATagToWhitespaceOnly() { + $this->assertException("whitespace", "Db", "ExceptionInput"); + $this->assertTrue(Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, ['name' => " "])); + } + + public function testRenameATagToAnInvalidValue() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + $this->assertTrue(Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, ['name' => []])); + } + + public function testCauseATagCollision() { + $this->assertException("constraintViolation", "Db", "ExceptionInput"); + Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, ['name' => "Fascinating"]); + } + + public function testSetThePropertiesOfAMissingTag() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->tagPropertiesSet("john.doe@example.com", 2112, ['name' => "Exciting"]); + } + + public function testSetThePropertiesOfAnInvalidTag() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->tagPropertiesSet("john.doe@example.com", -1, ['name' => "Exciting"]); + } + + public function testSetThePropertiesOfAnInvalidTagByName() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->tagPropertiesSet("john.doe@example.com", [], ['name' => "Exciting"], true); + } + + public function testSetThePropertiesOfATagForTheWrongOwner() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->tagPropertiesSet("john.doe@example.com", 3, ['name' => "Exciting"]); // tag ID 3 belongs to Jane + } + + public function testSetThePropertiesOfATagWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, ['name' => "Exciting"]); + } + + public function testListTagledSubscriptions() { + $exp = [1,5]; + $this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 1)); + $this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", "Interesting", true)); + $exp = [1,3,5]; + $this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 2)); + $this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", "Fascinating", true)); + $exp = []; + $this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 4)); + $this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", "Lonely", true)); + } + + public function testListTagledSubscriptionsForAMissingTag() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 3); + } + + public function testListTagledSubscriptionsForAnInvalidTag() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->tagSubscriptionsGet("john.doe@example.com", -1); + } + + public function testListTagledSubscriptionsWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 1); + } + + public function testApplyATagToSubscriptions() { + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [3,4]); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $state['arsse_tag_members']['rows'][1][2] = 1; + $state['arsse_tag_members']['rows'][] = [1,4,1]; + $this->compareExpectations($state); + } + + public function testClearATagFromSubscriptions() { + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [1,3], true); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $state['arsse_tag_members']['rows'][0][2] = 0; + $this->compareExpectations($state); + } + + public function testApplyATagToSubscriptionsByName() { + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [3,4], false, true); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $state['arsse_tag_members']['rows'][1][2] = 1; + $state['arsse_tag_members']['rows'][] = [1,4,1]; + $this->compareExpectations($state); + } + + public function testClearATagFromSubscriptionsByName() { + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [1,3], true, true); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $state['arsse_tag_members']['rows'][0][2] = 0; + $this->compareExpectations($state); + } + + public function testApplyATagToSubscriptionsWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [3,4]); + } + + public function testSummarizeTags() { + $exp = [ + ['tag_id' => 1, 'tag_name' => "Interesting", 'subscription_id' => 1, 'subscription_name' => "Lord of Carrots"], + ['tag_id' => 1, 'tag_name' => "Interesting", 'subscription_id' => 5, 'subscription_name' => "Feed Title"], + ['tag_id' => 2, 'tag_name' => "Fascinating", 'subscription_id' => 1, 'subscription_name' => "Lord of Carrots"], + ['tag_id' => 2, 'tag_name' => "Fascinating", 'subscription_id' => 3, 'subscription_name' => "Subscription Title"], + ['tag_id' => 2, 'tag_name' => "Fascinating", 'subscription_id' => 5, 'subscription_name' => "Feed Title"], + ]; + $this->assertResult($exp, Arsse::$db->tagSummarize("john.doe@example.com")); + } + + public function testSummarizeTagsWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->tagSummarize("john.doe@example.com"); + } +} From e6f70527cf0c6d1f939244ff1b1cd7cdf33f937d Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 7 Mar 2019 08:20:09 -0500 Subject: [PATCH 037/142] Simplify tag summary --- lib/Database.php | 9 +++------ tests/cases/Database/SeriesTag.php | 10 +++++----- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 043b993..f5409d1 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -2002,14 +2002,11 @@ class Database { } return $this->db->prepare( "SELECT - arsse_tags.id as tag_id, - arsse_tags.name as tag_name, - arsse_subscriptions.id as subscription_id, - coalesce(arsse_subscriptions.title, arsse_feeds.title) as subscription_name + arsse_tags.id as id, + arsse_tags.name as name, + arsse_tag_members.subscription as subscription FROM arsse_tag_members join arsse_tags on arsse_tags.id = arsse_tag_members.tag - join arsse_subscriptions on arsse_subscriptions.id = arsse_tag_members.subscription - join arsse_feeds on arsse_feeds.id = arsse_subscriptions.feed WHERE arsse_tags.owner = ? and assigned = 1", "str" )->run($user); diff --git a/tests/cases/Database/SeriesTag.php b/tests/cases/Database/SeriesTag.php index ea40d41..7c5aa1c 100644 --- a/tests/cases/Database/SeriesTag.php +++ b/tests/cases/Database/SeriesTag.php @@ -378,11 +378,11 @@ trait SeriesTag { public function testSummarizeTags() { $exp = [ - ['tag_id' => 1, 'tag_name' => "Interesting", 'subscription_id' => 1, 'subscription_name' => "Lord of Carrots"], - ['tag_id' => 1, 'tag_name' => "Interesting", 'subscription_id' => 5, 'subscription_name' => "Feed Title"], - ['tag_id' => 2, 'tag_name' => "Fascinating", 'subscription_id' => 1, 'subscription_name' => "Lord of Carrots"], - ['tag_id' => 2, 'tag_name' => "Fascinating", 'subscription_id' => 3, 'subscription_name' => "Subscription Title"], - ['tag_id' => 2, 'tag_name' => "Fascinating", 'subscription_id' => 5, 'subscription_name' => "Feed Title"], + ['id' => 1, 'name' => "Interesting", 'subscription' => 1], + ['id' => 1, 'name' => "Interesting", 'subscription' => 5], + ['id' => 2, 'name' => "Fascinating", 'subscription' => 1], + ['id' => 2, 'name' => "Fascinating", 'subscription' => 3], + ['id' => 2, 'name' => "Fascinating", 'subscription' => 5], ]; $this->assertResult($exp, Arsse::$db->tagSummarize("john.doe@example.com")); } From 5de1844f6d25b5940d6db8b82f47ec29ae4f4dbd Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 7 Mar 2019 11:07:22 -0500 Subject: [PATCH 038/142] Add article selection by tag --- lib/Context/ExclusionContext.php | 10 ++ lib/Database.php | 15 ++ tests/cases/Database/SeriesArticle.php | 218 +++++++++++++++---------- tests/cases/Misc/TestContext.php | 2 + 4 files changed, 158 insertions(+), 87 deletions(-) diff --git a/lib/Context/ExclusionContext.php b/lib/Context/ExclusionContext.php index d5299fe..1f91994 100644 --- a/lib/Context/ExclusionContext.php +++ b/lib/Context/ExclusionContext.php @@ -12,6 +12,8 @@ use JKingWeb\Arsse\Misc\Date; class ExclusionContext { public $folder; public $folderShallow; + public $tag; + public $tagName; public $subscription; public $edition; public $article; @@ -101,6 +103,14 @@ class ExclusionContext { return $this->act(__FUNCTION__, func_num_args(), $spec); } + public function tag(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function tagName(string $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + public function subscription(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } diff --git a/lib/Database.php b/lib/Database.php index f5409d1..dc2c74d 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1325,6 +1325,21 @@ class Database { $q->setWhereNot("arsse_articles.id in (select article from labelled where label_name = ?)", "str", $context->not->labelName); } } + if ($context->tag() || $context->not->tag() || $context->tagName() || $context->not->tagName()) { + $q->setCTE("tagged(id,name,subscription)","SELECT arsse_tags.id, arsse_tags.name, arsse_tag_members.subscription FROM arsse_tag_members join arsse_tags on arsse_tags.id = arsse_tag_members.tag WHERE arsse_tags.owner = ? and assigned = 1", "str", $user); + if ($context->tag()) { + $q->setWhere("arsse_subscriptions.id in (select subscription from tagged where id = ?)", "int", $context->tag); + } + if ($context->not->tag()) { + $q->setWhereNot("arsse_subscriptions.id in (select subscription from tagged where id = ?)", "int", $context->not->tag); + } + if ($context->tagName()) { + $q->setWhere("arsse_subscriptions.id in (select subscription from tagged where name = ?)", "str", $context->tagName); + } + if ($context->not->tagName()) { + $q->setWhereNot("arsse_subscriptions.id in (select subscription from tagged where name = ?)", "str", $context->not->tagName); + } + } if ($context->folder()) { // add a common table expression to list the folder and its children so that we select from the entire subtree $q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on parent = folder", "int", $context->folder); diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index f652c6f..9fb893b 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -28,6 +28,28 @@ trait SeriesArticle { ["john.doe@example.net", "", "John Doe"], ], ], + 'arsse_feeds' => [ + 'columns' => [ + 'id' => "int", + 'url' => "str", + 'title' => "str", + ], + 'rows' => [ + [1,"http://example.com/1", "Feed 1"], + [2,"http://example.com/2", "Feed 2"], + [3,"http://example.com/3", "Feed 3"], + [4,"http://example.com/4", "Feed 4"], + [5,"http://example.com/5", "Feed 5"], + [6,"http://example.com/6", "Feed 6"], + [7,"http://example.com/7", "Feed 7"], + [8,"http://example.com/8", "Feed 8"], + [9,"http://example.com/9", "Feed 9"], + [10,"http://example.com/10", "Feed 10"], + [11,"http://example.com/11", "Feed 11"], + [12,"http://example.com/12", "Feed 12"], + [13,"http://example.com/13", "Feed 13"], + ] + ], 'arsse_folders' => [ 'columns' => [ 'id' => "int", @@ -47,26 +69,21 @@ trait SeriesArticle { [9, "john.doe@example.net", null, "Politics"], ] ], - 'arsse_feeds' => [ + 'arsse_tags' => [ 'columns' => [ - 'id' => "int", - 'url' => "str", - 'title' => "str", + 'id' => "int", + 'owner' => "str", + 'name' => "str", ], 'rows' => [ - [1,"http://example.com/1", "Feed 1"], - [2,"http://example.com/2", "Feed 2"], - [3,"http://example.com/3", "Feed 3"], - [4,"http://example.com/4", "Feed 4"], - [5,"http://example.com/5", "Feed 5"], - [6,"http://example.com/6", "Feed 6"], - [7,"http://example.com/7", "Feed 7"], - [8,"http://example.com/8", "Feed 8"], - [9,"http://example.com/9", "Feed 9"], - [10,"http://example.com/10", "Feed 10"], - [11,"http://example.com/11", "Feed 11"], - [12,"http://example.com/12", "Feed 12"], - [13,"http://example.com/13", "Feed 13"], + [1, "john.doe@example.com", "Technology"], + [2, "john.doe@example.com", "Software"], + [3, "john.doe@example.com", "Rocketry"], + [4, "jane.doe@example.com", "Politics"], + [5, "john.doe@example.com", "Politics"], + [6, "john.doe@example.net", "Technology"], + [7, "john.doe@example.net", "Software"], + [8, "john.doe@example.net", "Politics"], ] ], 'arsse_subscriptions' => [ @@ -94,6 +111,25 @@ trait SeriesArticle { [14,"john.doe@example.net",4, 7,null], ] ], + 'arsse_tag_members' => [ + 'columns' => [ + 'tag' => "int", + 'subscription' => "int", + 'assigned' => "bool", + ], + 'rows' => [ + [1,3,1], + [1,4,1], + [2,4,1], + [5,1,0], + [5,4,1], + [5,5,1], + [6,13,1], + [6,14,1], + [7,13,1], + [8,12,1], + ], + ], 'arsse_articles' => [ 'columns' => [ 'id' => "int", @@ -387,76 +423,84 @@ trait SeriesArticle { public function provideContextMatches() { return [ - "Blank context" => [new Context, [1,2,3,4,5,6,7,8,19,20]], - "Folder tree" => [(new Context)->folder(1), [5,6,7,8]], - "Leaf folder" => [(new Context)->folder(6), [7,8]], - "Root folder only" => [(new Context)->folderShallow(0), [1,2,3,4]], - "Shallow folder" => [(new Context)->folderShallow(1), [5,6]], - "Subscription" => [(new Context)->subscription(5), [19,20]], - "Unread" => [(new Context)->subscription(5)->unread(true), [20]], - "Read" => [(new Context)->subscription(5)->unread(false), [19]], - "Starred" => [(new Context)->starred(true), [1,20]], - "Unstarred" => [(new Context)->starred(false), [2,3,4,5,6,7,8,19]], - "Starred and Read" => [(new Context)->starred(true)->unread(false), [1]], - "Starred and Read in subscription" => [(new Context)->starred(true)->unread(false)->subscription(5), []], - "Annotated" => [(new Context)->annotated(true), [2]], - "Not annotated" => [(new Context)->annotated(false), [1,3,4,5,6,7,8,19,20]], - "Labelled" => [(new Context)->labelled(true), [1,5,8,19,20]], - "Not labelled" => [(new Context)->labelled(false), [2,3,4,6,7]], - "Not after edition 999" => [(new Context)->subscription(5)->latestEdition(999), [19]], - "Not after edition 19" => [(new Context)->subscription(5)->latestEdition(19), [19]], - "Not before edition 999" => [(new Context)->subscription(5)->oldestEdition(999), [20]], - "Not before edition 1001" => [(new Context)->subscription(5)->oldestEdition(1001), [20]], - "Not after article 3" => [(new Context)->latestArticle(3), [1,2,3]], - "Not before article 19" => [(new Context)->oldestArticle(19), [19,20]], - "Modified by author since 2005" => [(new Context)->modifiedSince("2005-01-01T00:00:00Z"), [2,4,6,8,20]], - "Modified by author since 2010" => [(new Context)->modifiedSince("2010-01-01T00:00:00Z"), [2,4,6,8,20]], - "Not modified by author since 2005" => [(new Context)->notModifiedSince("2005-01-01T00:00:00Z"), [1,3,5,7,19]], - "Not modified by author since 2000" => [(new Context)->notModifiedSince("2000-01-01T00:00:00Z"), [1,3,5,7,19]], - "Marked or labelled since 2014" => [(new Context)->markedSince("2014-01-01T00:00:00Z"), [8,19]], - "Marked or labelled since 2010" => [(new Context)->markedSince("2010-01-01T00:00:00Z"), [2,4,6,8,19,20]], - "Not marked or labelled since 2014" => [(new Context)->notMarkedSince("2014-01-01T00:00:00Z"), [1,2,3,4,5,6,7,20]], - "Not marked or labelled since 2005" => [(new Context)->notMarkedSince("2005-01-01T00:00:00Z"), [1,3,5,7]], - "Marked or labelled between 2000 and 2015" => [(new Context)->markedSince("2000-01-01T00:00:00Z")->notMarkedSince("2015-12-31T23:59:59Z"), [1,2,3,4,5,6,7,8,20]], - "Marked or labelled in 2010" => [(new Context)->markedSince("2010-01-01T00:00:00Z")->notMarkedSince("2010-12-31T23:59:59Z"), [2,4,6,20]], - "Paged results" => [(new Context)->limit(2)->oldestEdition(4), [4,5]], - "Reversed paged results" => [(new Context)->limit(2)->latestEdition(7)->reverse(true), [7,6]], - "With label ID 1" => [(new Context)->label(1), [1,19]], - "With label ID 2" => [(new Context)->label(2), [1,5,20]], - "With label 'Interesting'" => [(new Context)->labelName("Interesting"), [1,19]], - "With label 'Fascinating'" => [(new Context)->labelName("Fascinating"), [1,5,20]], - "Article ID 20" => [(new Context)->article(20), [20]], - "Edition ID 1001" => [(new Context)->edition(1001), [20]], - "Multiple articles" => [(new Context)->articles([1,20,50]), [1,20]], - "Multiple starred articles" => [(new Context)->articles([1,2,3])->starred(true), [1]], - "Multiple unstarred articles" => [(new Context)->articles([1,2,3])->starred(false), [2,3]], - "Multiple articles" => [(new Context)->articles([1,20,50]), [1,20]], - "Multiple editions" => [(new Context)->editions([1,1001,50]), [1,20]], - "150 articles" => [(new Context)->articles(range(1, Database::LIMIT_SET_SIZE * 3)), [1,2,3,4,5,6,7,8,19,20]], - "Search title or content 1" => [(new Context)->searchTerms(["Article"]), [1,2,3]], - "Search title or content 2" => [(new Context)->searchTerms(["one", "first"]), [1]], - "Search title or content 3" => [(new Context)->searchTerms(["one first"]), []], - "Search title 1" => [(new Context)->titleTerms(["two"]), [2]], - "Search title 2" => [(new Context)->titleTerms(["title two"]), [2]], - "Search title 3" => [(new Context)->titleTerms(["two", "title"]), [2]], - "Search title 4" => [(new Context)->titleTerms(["two title"]), []], - "Search note 1" => [(new Context)->annotationTerms(["some"]), [2]], - "Search note 2" => [(new Context)->annotationTerms(["some Note"]), [2]], - "Search note 3" => [(new Context)->annotationTerms(["note", "some"]), [2]], - "Search note 4" => [(new Context)->annotationTerms(["some", "sauce"]), []], - "Search author 1" => [(new Context)->authorTerms(["doe"]), [4,5,6,7]], - "Search author 2" => [(new Context)->authorTerms(["jane doe"]), [6,7]], - "Search author 3" => [(new Context)->authorTerms(["doe", "jane"]), [6,7]], - "Search author 4" => [(new Context)->authorTerms(["doe jane"]), []], - "Folder tree 1 excluding subscription 4" => [(new Context)->not->subscription(4)->folder(1), [5,6]], - "Folder tree 1 excluding articles 7 and 8" => [(new Context)->folder(1)->not->articles([7,8]), [5,6]], - "Folder tree 1 excluding no articles" => [(new Context)->folder(1)->not->articles([]), [5,6,7,8]], - "Marked or labelled between 2000 and 2015 excluding in 2010" => [(new Context)->markedSince("2000-01-01T00:00:00Z")->notMarkedSince("2015-12-31T23:59:59")->not->markedSince("2010-01-01T00:00:00Z")->not->notMarkedSince("2010-12-31T23:59:59Z"), [1,3,5,7,8]], - "Search with exclusion" => [(new Context)->searchTerms(["Article"])->not->searchTerms(["one", "two"]), [3]], - "Excluded folder tree" => [(new Context)->not->folder(1), [1,2,3,4,19,20]], - "Excluding label ID 2" => [(new Context)->not->label(2), [2,3,4,6,7,8,19]], - "Excluding label 'Fascinating'" => [(new Context)->not->labelName("Fascinating"), [2,3,4,6,7,8,19]], - "Search 501 terms" => [(new Context)->searchTerms(array_merge(range(1,500),[str_repeat("a", 1000)])), []], + 'Blank context' => [new Context, [1,2,3,4,5,6,7,8,19,20]], + 'Folder tree' => [(new Context)->folder(1), [5,6,7,8]], + 'Leaf folder' => [(new Context)->folder(6), [7,8]], + 'Root folder only' => [(new Context)->folderShallow(0), [1,2,3,4]], + 'Shallow folder' => [(new Context)->folderShallow(1), [5,6]], + 'Subscription' => [(new Context)->subscription(5), [19,20]], + 'Unread' => [(new Context)->subscription(5)->unread(true), [20]], + 'Read' => [(new Context)->subscription(5)->unread(false), [19]], + 'Starred' => [(new Context)->starred(true), [1,20]], + 'Unstarred' => [(new Context)->starred(false), [2,3,4,5,6,7,8,19]], + 'Starred and Read' => [(new Context)->starred(true)->unread(false), [1]], + 'Starred and Read in subscription' => [(new Context)->starred(true)->unread(false)->subscription(5), []], + 'Annotated' => [(new Context)->annotated(true), [2]], + 'Not annotated' => [(new Context)->annotated(false), [1,3,4,5,6,7,8,19,20]], + 'Labelled' => [(new Context)->labelled(true), [1,5,8,19,20]], + 'Not labelled' => [(new Context)->labelled(false), [2,3,4,6,7]], + 'Not after edition 999' => [(new Context)->subscription(5)->latestEdition(999), [19]], + 'Not after edition 19' => [(new Context)->subscription(5)->latestEdition(19), [19]], + 'Not before edition 999' => [(new Context)->subscription(5)->oldestEdition(999), [20]], + 'Not before edition 1001' => [(new Context)->subscription(5)->oldestEdition(1001), [20]], + 'Not after article 3' => [(new Context)->latestArticle(3), [1,2,3]], + 'Not before article 19' => [(new Context)->oldestArticle(19), [19,20]], + 'Modified by author since 2005' => [(new Context)->modifiedSince("2005-01-01T00:00:00Z"), [2,4,6,8,20]], + 'Modified by author since 2010' => [(new Context)->modifiedSince("2010-01-01T00:00:00Z"), [2,4,6,8,20]], + 'Not modified by author since 2005' => [(new Context)->notModifiedSince("2005-01-01T00:00:00Z"), [1,3,5,7,19]], + 'Not modified by author since 2000' => [(new Context)->notModifiedSince("2000-01-01T00:00:00Z"), [1,3,5,7,19]], + 'Marked or labelled since 2014' => [(new Context)->markedSince("2014-01-01T00:00:00Z"), [8,19]], + 'Marked or labelled since 2010' => [(new Context)->markedSince("2010-01-01T00:00:00Z"), [2,4,6,8,19,20]], + 'Not marked or labelled since 2014' => [(new Context)->notMarkedSince("2014-01-01T00:00:00Z"), [1,2,3,4,5,6,7,20]], + 'Not marked or labelled since 2005' => [(new Context)->notMarkedSince("2005-01-01T00:00:00Z"), [1,3,5,7]], + 'Marked or labelled between 2000 and 2015' => [(new Context)->markedSince("2000-01-01T00:00:00Z")->notMarkedSince("2015-12-31T23:59:59Z"), [1,2,3,4,5,6,7,8,20]], + 'Marked or labelled in 2010' => [(new Context)->markedSince("2010-01-01T00:00:00Z")->notMarkedSince("2010-12-31T23:59:59Z"), [2,4,6,20]], + 'Paged results' => [(new Context)->limit(2)->oldestEdition(4), [4,5]], + 'Reversed paged results' => [(new Context)->limit(2)->latestEdition(7)->reverse(true), [7,6]], + 'With label ID 1' => [(new Context)->label(1), [1,19]], + 'With label ID 2' => [(new Context)->label(2), [1,5,20]], + 'With label "Interesting"' => [(new Context)->labelName("Interesting"), [1,19]], + 'With label "Fascinating"' => [(new Context)->labelName("Fascinating"), [1,5,20]], + 'Article ID 20' => [(new Context)->article(20), [20]], + 'Edition ID 1001' => [(new Context)->edition(1001), [20]], + 'Multiple articles' => [(new Context)->articles([1,20,50]), [1,20]], + 'Multiple starred articles' => [(new Context)->articles([1,2,3])->starred(true), [1]], + 'Multiple unstarred articles' => [(new Context)->articles([1,2,3])->starred(false), [2,3]], + 'Multiple articles' => [(new Context)->articles([1,20,50]), [1,20]], + 'Multiple editions' => [(new Context)->editions([1,1001,50]), [1,20]], + '150 articles' => [(new Context)->articles(range(1, Database::LIMIT_SET_SIZE * 3)), [1,2,3,4,5,6,7,8,19,20]], + 'Search title or content 1' => [(new Context)->searchTerms(["Article"]), [1,2,3]], + 'Search title or content 2' => [(new Context)->searchTerms(["one", "first"]), [1]], + 'Search title or content 3' => [(new Context)->searchTerms(["one first"]), []], + 'Search title 1' => [(new Context)->titleTerms(["two"]), [2]], + 'Search title 2' => [(new Context)->titleTerms(["title two"]), [2]], + 'Search title 3' => [(new Context)->titleTerms(["two", "title"]), [2]], + 'Search title 4' => [(new Context)->titleTerms(["two title"]), []], + 'Search note 1' => [(new Context)->annotationTerms(["some"]), [2]], + 'Search note 2' => [(new Context)->annotationTerms(["some Note"]), [2]], + 'Search note 3' => [(new Context)->annotationTerms(["note", "some"]), [2]], + 'Search note 4' => [(new Context)->annotationTerms(["some", "sauce"]), []], + 'Search author 1' => [(new Context)->authorTerms(["doe"]), [4,5,6,7]], + 'Search author 2' => [(new Context)->authorTerms(["jane doe"]), [6,7]], + 'Search author 3' => [(new Context)->authorTerms(["doe", "jane"]), [6,7]], + 'Search author 4' => [(new Context)->authorTerms(["doe jane"]), []], + 'Folder tree 1 excluding subscription 4' => [(new Context)->not->subscription(4)->folder(1), [5,6]], + 'Folder tree 1 excluding articles 7 and 8' => [(new Context)->folder(1)->not->articles([7,8]), [5,6]], + 'Folder tree 1 excluding no articles' => [(new Context)->folder(1)->not->articles([]), [5,6,7,8]], + 'Marked or labelled between 2000 and 2015 excluding in 2010' => [(new Context)->markedSince("2000-01-01T00:00:00Z")->notMarkedSince("2015-12-31T23:59:59")->not->markedSince("2010-01-01T00:00:00Z")->not->notMarkedSince("2010-12-31T23:59:59Z"), [1,3,5,7,8]], + 'Search with exclusion' => [(new Context)->searchTerms(["Article"])->not->searchTerms(["one", "two"]), [3]], + 'Excluded folder tree' => [(new Context)->not->folder(1), [1,2,3,4,19,20]], + 'Excluding label ID 2' => [(new Context)->not->label(2), [2,3,4,6,7,8,19]], + 'Excluding label "Fascinating"' => [(new Context)->not->labelName("Fascinating"), [2,3,4,6,7,8,19]], + 'Search 501 terms' => [(new Context)->searchTerms(array_merge(range(1,500),[str_repeat("a", 1000)])), []], + 'With tag ID 1' => [(new Context)->tag(1), [5,6,7,8]], + 'With tag ID 5' => [(new Context)->tag(5), [7,8,19,20]], + 'With tag "Technology"' => [(new Context)->tagName("Technology"), [5,6,7,8]], + 'With tag "Politics"' => [(new Context)->tagName("Politics"), [7,8,19,20]], + 'Excluding tag ID 1' => [(new Context)->not->tag(1), [1,2,3,4,19,20]], + 'Excluding tag ID 5' => [(new Context)->not->tag(5), [1,2,3,4,5,6]], + 'Excluding tag "Technology"' => [(new Context)->not->tagName("Technology"), [1,2,3,4,19,20]], + 'Excluding tag "Politics"' => [(new Context)->not->tagName("Politics"), [1,2,3,4,5,6]], ]; } diff --git a/tests/cases/Misc/TestContext.php b/tests/cases/Misc/TestContext.php index d134c0f..e85d58e 100644 --- a/tests/cases/Misc/TestContext.php +++ b/tests/cases/Misc/TestContext.php @@ -30,6 +30,8 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { 'offset' => 5, 'folder' => 42, 'folderShallow' => 42, + 'tag' => 44, + 'tagName' => "XLIV", 'subscription' => 2112, 'article' => 255, 'edition' => 65535, From 38bdde116702df910afe73936a71fd33877ef76a Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 9 Mar 2019 16:23:56 -0500 Subject: [PATCH 039/142] Add access tokens to the db, with relevant code Tokens are similar to sessions in that they stand in for users, but the protocol handlers will manage them; Fever login hashes are the originating use case for them. These must never expire, for example, and we need to specify their values. This commit also performs a bit of database clean-up --- lib/Database.php | 54 ++++++++ lib/Db/MySQL/Driver.php | 2 +- sql/MySQL/4.sql | 18 +++ sql/PostgreSQL/4.sql | 17 +++ sql/SQLite3/1.sql | 4 +- sql/SQLite3/4.sql | 53 ++++++++ tests/cases/Database/Base.php | 1 + tests/cases/Database/SeriesArticle.php | 9 +- tests/cases/Database/SeriesCleanup.php | 30 ++++- tests/cases/Database/SeriesFeed.php | 5 +- tests/cases/Database/SeriesFolder.php | 5 +- tests/cases/Database/SeriesLabel.php | 9 +- tests/cases/Database/SeriesSession.php | 5 +- tests/cases/Database/SeriesSubscription.php | 5 +- tests/cases/Database/SeriesTag.php | 9 +- tests/cases/Database/SeriesToken.php | 135 ++++++++++++++++++++ tests/cases/Database/SeriesUser.php | 12 +- 17 files changed, 333 insertions(+), 40 deletions(-) create mode 100644 tests/cases/Database/SeriesToken.php diff --git a/lib/Database.php b/lib/Database.php index dc2c74d..01cd91a 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -27,6 +27,7 @@ use JKingWeb\Arsse\Misc\ValueInfo; * - Editions, identifying authorial modifications to articles * - Labels, which belong to users and can be assigned to multiple articles * - Sessions, used by some protocols to identify users across periods of time + * - Tokens, similar to sessions, but with more control over their properties * - Metadata, used internally by the server * * The various methods of this class perform operations on these things, with @@ -380,6 +381,59 @@ class Database { return (($now + $diff) >= $expiry->getTimestamp()); } + /** Creates a new token for the given user in the given class + * + * @param string $user The user for whom to create the token + * @param string $class The class of the token e.g. the protocol name + * @param string|null $id The value of the token; if none is provided a UUID will be generated + * @param \DateTimeInterface|null $expires An optional expiry date and time for the token + */ + public function tokenCreate(string $user, string $class, string $id = null, \DateTimeInterface $expires = null): string { + // If the user isn't authorized to perform this action then throw an exception. + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + // generate a token if it's not provided + $id = $id ?? UUID::mint()->hex; + // save the token to the database + $this->db->prepare("INSERT INTO arsse_tokens(id,class,\"user\",expires) values(?,?,?,?)", "str", "str", "str", "datetime")->run($id, $class, $user, $expires); + // return the ID + return $id; + } + + /** Revokes one or all tokens for a user in a class + * + * @param string $user The user who owns the token to be revoked + * @param string $class The class of the token e.g. the protocol name + * @param string|null $id The ID of a specific token, or null for all tokens in the class + */ + public function tokenRevoke(string $user, string $class, string $id = null): bool { + // If the user isn't authorized to perform this action then throw an exception. + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + if (is_null($id)) { + $out = $this->db->prepare("DELETE FROM arsse_tokens where \"user\" = ? and class = ?", "str", "str")->run($user, $class)->changes(); + } else { + $out = $this->db->prepare("DELETE FROM arsse_tokens where \"user\" = ? and class = ? and id = ?", "str", "str", "str")->run($user, $class, $id)->changes(); + } + return (bool) $out; + } + + /** Look up data associated with a token */ + public function tokenLookup(string $class, string $id): array { + $out = $this->db->prepare("SELECT id,class,\"user\",created,expires from arsse_tokens where class = ? and id = ? and expires > CURRENT_TIMESTAMP", "str", "str")->run($class, $id)->getRow(); + if (!$out) { + throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "token", 'id' => $id]); + } + return $out; + } + + /** Deletes expires tokens from the database, returning the number of deleted tokens */ + public function tokenCleanup(): int { + return $this->db->query("DELETE FROM arsse_tokens where expires < CURRENT_TIMESTAMP")->changes(); + } + /** Adds a folder for containing newsfeed subscriptions, returning an integer identifying the created folder * * The $data array may contain the following keys: diff --git a/lib/Db/MySQL/Driver.php b/lib/Db/MySQL/Driver.php index edd5f77..cec575b 100644 --- a/lib/Db/MySQL/Driver.php +++ b/lib/Db/MySQL/Driver.php @@ -41,7 +41,7 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { $this->exec($q); } // get the maximum packet size; parameter strings larger than this size need to be chunked - $this->packetSize = (int) $this->query("select variable_value from performance_schema.session_variables where variable_name = 'max_allowed_packet'")->getValue(); + $this->packetSize = (int) $this->query("SELECT variable_value from performance_schema.session_variables where variable_name = 'max_allowed_packet'")->getValue(); } public static function makeSetupQueries(): array { diff --git a/sql/MySQL/4.sql b/sql/MySQL/4.sql index aa073a6..bde1212 100644 --- a/sql/MySQL/4.sql +++ b/sql/MySQL/4.sql @@ -20,4 +20,22 @@ create table arsse_tag_members( primary key(tag,subscription) ) character set utf8mb4 collate utf8mb4_unicode_ci; +create table arsse_tokens( + id varchar(255) not null, + class varchar(255) not null, + "user" varchar(255) not null references arsse_users(id) on delete cascade on update cascade, + created datetime(0) not null default CURRENT_TIMESTAMP, + expires datetime(0), + primary key(id,class) +) character set utf8mb4 collate utf8mb4_unicode_ci; + +alter table arsse_users drop column name; +alter table arsse_users drop column avatar_type; +alter table arsse_users drop column avatar_data; +alter table arsse_users drop column admin; +alter table arsse_users drop column rights; + +drop table arsse_users_meta; + + update arsse_meta set value = '5' where "key" = 'schema_version'; diff --git a/sql/PostgreSQL/4.sql b/sql/PostgreSQL/4.sql index e0cd8eb..6096211 100644 --- a/sql/PostgreSQL/4.sql +++ b/sql/PostgreSQL/4.sql @@ -20,4 +20,21 @@ create table arsse_tag_members( primary key(tag,subscription) ); +create table arsse_tokens( + id text, + class text not null, + "user" text not null references arsse_users(id) on delete cascade on update cascade, + created timestamp(0) without time zone not null default CURRENT_TIMESTAMP, + expires timestamp(0) without time zone, + primary key(id,class) +); + +alter table arsse_users drop column name; +alter table arsse_users drop column avatar_type; +alter table arsse_users drop column avatar_data; +alter table arsse_users drop column admin; +alter table arsse_users drop column rights; + +drop table arsse_users_meta; + update arsse_meta set value = '5' where "key" = 'schema_version'; diff --git a/sql/SQLite3/1.sql b/sql/SQLite3/1.sql index 3817645..7f213e1 100644 --- a/sql/SQLite3/1.sql +++ b/sql/SQLite3/1.sql @@ -5,8 +5,8 @@ create table arsse_sessions( -- sessions for Tiny Tiny RSS (and possibly others) id text primary key, -- UUID of session - created text not null default CURRENT_TIMESTAMP, -- Session start timestamp - expires text not null, -- Time at which session is no longer valid + created text not null default CURRENT_TIMESTAMP, -- session start timestamp + expires text not null, -- time at which session is no longer valid user text not null references arsse_users(id) on delete cascade on update cascade -- user associated with the session ) without rowid; diff --git a/sql/SQLite3/4.sql b/sql/SQLite3/4.sql index aa7cfbd..f7cdd20 100644 --- a/sql/SQLite3/4.sql +++ b/sql/SQLite3/4.sql @@ -20,6 +20,59 @@ create table arsse_tag_members( primary key(tag,subscription) -- only one association of a given tag to a given subscription ) without rowid; +create table arsse_tokens( +-- access tokens that are managed by the protocol handler and may optionally expire + id text, -- token identifier + class text not null, -- symbolic name of the protocol handler managing the token + user text not null references arsse_users(id) on delete cascade on update cascade, -- user associated with the token + created text not null default CURRENT_TIMESTAMP, -- creation timestamp + expires text, -- time at which token is no longer valid + primary key(id,class) -- tokens must be unique for their class +) without rowid; + + +-- clean up the user tables to remove unused stuff +-- if any of the removed things are implemented in future, necessary structures will be added back in at that time + +create table arsse_users_new( +-- users + id text primary key not null collate nocase, -- user id + password text -- password, salted and hashed; if using external authentication this would be blank +) without rowid; +insert into arsse_users_new select id,password from arsse_users; +drop table arsse_users; +alter table arsse_users_new rename to arsse_users; + +drop table arsse_users_meta; + + +-- use WITHOUT ROWID tables when possible; this is an SQLite-specific change + +create table arsse_meta_new( +-- application metadata + key text primary key not null, -- metadata key + value text -- metadata value, serialized as a string +) without rowid; +insert into arsse_meta_new select * from arsse_meta; +drop table arsse_meta; +alter table arsse_meta_new rename to arsse_meta; + +create table arsse_marks_new( +-- users' actions on newsfeed entries + article integer not null references arsse_articles(id) on delete cascade, -- article associated with the marks + subscription integer not null references arsse_subscriptions(id) on delete cascade on update cascade, -- subscription associated with the marks; the subscription in turn belongs to a user + read boolean not null default 0, -- whether the article has been read + starred boolean not null default 0, -- whether the article is starred + modified text, -- time at which an article was last modified by a given user + note text not null default '', -- Tiny Tiny RSS freeform user note + touched boolean not null default 0, -- used to indicate a record has been modified during the course of some transactions + primary key(article,subscription) -- no more than one mark-set per article per user +) without rowid; +insert into arsse_marks_new select * from arsse_marks; +drop table arsse_marks; +alter table arsse_marks_new rename to arsse_marks; + + -- set version marker pragma user_version = 5; update arsse_meta set value = '5' where "key" = 'schema_version'; diff --git a/tests/cases/Database/Base.php b/tests/cases/Database/Base.php index 9e140c4..47803ff 100644 --- a/tests/cases/Database/Base.php +++ b/tests/cases/Database/Base.php @@ -20,6 +20,7 @@ abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest { use SeriesMeta; use SeriesUser; use SeriesSession; + use SeriesToken; use SeriesFolder; use SeriesFeed; use SeriesSubscription; diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 9fb893b..5340fcc 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -19,13 +19,12 @@ trait SeriesArticle { 'columns' => [ 'id' => 'str', 'password' => 'str', - 'name' => 'str', ], 'rows' => [ - ["jane.doe@example.com", "", "Jane Doe"], - ["john.doe@example.com", "", "John Doe"], - ["john.doe@example.org", "", "John Doe"], - ["john.doe@example.net", "", "John Doe"], + ["jane.doe@example.com", ""], + ["john.doe@example.com", ""], + ["john.doe@example.org", ""], + ["john.doe@example.net", ""], ], ], 'arsse_feeds' => [ diff --git a/tests/cases/Database/SeriesCleanup.php b/tests/cases/Database/SeriesCleanup.php index f8b4199..6d80a7e 100644 --- a/tests/cases/Database/SeriesCleanup.php +++ b/tests/cases/Database/SeriesCleanup.php @@ -29,11 +29,10 @@ trait SeriesCleanup { 'columns' => [ 'id' => 'str', 'password' => 'str', - 'name' => 'str', ], 'rows' => [ - ["jane.doe@example.com", "", "Jane Doe"], - ["john.doe@example.com", "", "John Doe"], + ["jane.doe@example.com", ""], + ["john.doe@example.com", ""], ], ], 'arsse_sessions' => [ @@ -51,6 +50,20 @@ trait SeriesCleanup { ["e", $daysago, $nowish, "jane.doe@example.com"], // created more than a day ago and expired, thus deleted ], ], + 'arsse_tokens' => [ + 'columns' => [ + 'id' => "str", + 'class' => "str", + 'user' => "str", + 'expires' => "datetime", + ], + 'rows' => [ + ["80fa94c1a11f11e78667001e673b2560", "fever.login", "jane.doe@example.com", $faroff], + ["27c6de8da13311e78667001e673b2560", "fever.login", "jane.doe@example.com", $weeksago], // expired + ["ab3b3eb8a13311e78667001e673b2560", "class.class", "jane.doe@example.com", null], + ["da772f8fa13c11e78667001e673b2560", "class.class", "john.doe@example.com", $soon], + ], + ], 'arsse_feeds' => [ 'columns' => [ 'id' => "int", @@ -226,4 +239,15 @@ trait SeriesCleanup { } $this->compareExpectations($state); } + + public function testCleanUpExpiredTokens() { + Arsse::$db->tokenCleanup(); + $state = $this->primeExpectations($this->data, [ + 'arsse_tokens' => ["id", "class"] + ]); + foreach ([2] as $id) { + unset($state['arsse_tokens']['rows'][$id - 1]); + } + $this->compareExpectations($state); + } } diff --git a/tests/cases/Database/SeriesFeed.php b/tests/cases/Database/SeriesFeed.php index c7cd2a4..a01f064 100644 --- a/tests/cases/Database/SeriesFeed.php +++ b/tests/cases/Database/SeriesFeed.php @@ -22,11 +22,10 @@ trait SeriesFeed { 'columns' => [ 'id' => 'str', 'password' => 'str', - 'name' => 'str', ], 'rows' => [ - ["jane.doe@example.com", "", "Jane Doe"], - ["john.doe@example.com", "", "John Doe"], + ["jane.doe@example.com", ""], + ["john.doe@example.com", ""], ], ], 'arsse_feeds' => [ diff --git a/tests/cases/Database/SeriesFolder.php b/tests/cases/Database/SeriesFolder.php index 99c9f1a..9643b64 100644 --- a/tests/cases/Database/SeriesFolder.php +++ b/tests/cases/Database/SeriesFolder.php @@ -16,11 +16,10 @@ trait SeriesFolder { 'columns' => [ 'id' => 'str', 'password' => 'str', - 'name' => 'str', ], 'rows' => [ - ["jane.doe@example.com", "", "Jane Doe"], - ["john.doe@example.com", "", "John Doe"], + ["jane.doe@example.com", ""], + ["john.doe@example.com", ""], ], ], 'arsse_folders' => [ diff --git a/tests/cases/Database/SeriesLabel.php b/tests/cases/Database/SeriesLabel.php index e6fc426..9ffc01b 100644 --- a/tests/cases/Database/SeriesLabel.php +++ b/tests/cases/Database/SeriesLabel.php @@ -18,13 +18,12 @@ trait SeriesLabel { 'columns' => [ 'id' => 'str', 'password' => 'str', - 'name' => 'str', ], 'rows' => [ - ["jane.doe@example.com", "", "Jane Doe"], - ["john.doe@example.com", "", "John Doe"], - ["john.doe@example.org", "", "John Doe"], - ["john.doe@example.net", "", "John Doe"], + ["jane.doe@example.com", ""], + ["john.doe@example.com", ""], + ["john.doe@example.org", ""], + ["john.doe@example.net", ""], ], ], 'arsse_folders' => [ diff --git a/tests/cases/Database/SeriesSession.php b/tests/cases/Database/SeriesSession.php index c986742..74a809c 100644 --- a/tests/cases/Database/SeriesSession.php +++ b/tests/cases/Database/SeriesSession.php @@ -27,11 +27,10 @@ trait SeriesSession { 'columns' => [ 'id' => 'str', 'password' => 'str', - 'name' => 'str', ], 'rows' => [ - ["jane.doe@example.com", "", "Jane Doe"], - ["john.doe@example.com", "", "John Doe"], + ["jane.doe@example.com", ""], + ["john.doe@example.com", ""], ], ], 'arsse_sessions' => [ diff --git a/tests/cases/Database/SeriesSubscription.php b/tests/cases/Database/SeriesSubscription.php index 0adac9e..9756a28 100644 --- a/tests/cases/Database/SeriesSubscription.php +++ b/tests/cases/Database/SeriesSubscription.php @@ -18,11 +18,10 @@ trait SeriesSubscription { 'columns' => [ 'id' => 'str', 'password' => 'str', - 'name' => 'str', ], 'rows' => [ - ["jane.doe@example.com", "", "Jane Doe"], - ["john.doe@example.com", "", "John Doe"], + ["jane.doe@example.com", ""], + ["john.doe@example.com", ""], ], ], 'arsse_folders' => [ diff --git a/tests/cases/Database/SeriesTag.php b/tests/cases/Database/SeriesTag.php index 7c5aa1c..404e2f1 100644 --- a/tests/cases/Database/SeriesTag.php +++ b/tests/cases/Database/SeriesTag.php @@ -17,13 +17,12 @@ trait SeriesTag { 'columns' => [ 'id' => 'str', 'password' => 'str', - 'name' => 'str', ], 'rows' => [ - ["jane.doe@example.com", "", "Jane Doe"], - ["john.doe@example.com", "", "John Doe"], - ["john.doe@example.org", "", "John Doe"], - ["john.doe@example.net", "", "John Doe"], + ["jane.doe@example.com", ""], + ["john.doe@example.com", ""], + ["john.doe@example.org", ""], + ["john.doe@example.net", ""], ], ], 'arsse_feeds' => [ diff --git a/tests/cases/Database/SeriesToken.php b/tests/cases/Database/SeriesToken.php new file mode 100644 index 0000000..738fc58 --- /dev/null +++ b/tests/cases/Database/SeriesToken.php @@ -0,0 +1,135 @@ +data = [ + 'arsse_users' => [ + 'columns' => [ + 'id' => 'str', + 'password' => 'str', + ], + 'rows' => [ + ["jane.doe@example.com", ""], + ["john.doe@example.com", ""], + ], + ], + 'arsse_tokens' => [ + 'columns' => [ + 'id' => "str", + 'class' => "str", + 'user' => "str", + 'expires' => "datetime", + ], + 'rows' => [ + ["80fa94c1a11f11e78667001e673b2560", "fever.login", "jane.doe@example.com", $faroff], + ["27c6de8da13311e78667001e673b2560", "fever.login", "jane.doe@example.com", $past], // expired + ["ab3b3eb8a13311e78667001e673b2560", "class.class", "jane.doe@example.com", null], + ["da772f8fa13c11e78667001e673b2560", "class.class", "john.doe@example.com", $future], + ], + ], + ]; + } + + protected function tearDownSeriesToken() { + unset($this->data); + } + + public function testLookUpAValidToken() { + $exp1 = [ + 'id' => "80fa94c1a11f11e78667001e673b2560", + 'class' => "fever.login", + 'user' => "jane.doe@example.com" + ]; + $exp2 = [ + 'id' => "da772f8fa13c11e78667001e673b2560", + 'class' => "class.class", + 'user' => "john.doe@example.com" + ]; + $this->assertArraySubset($exp1, Arsse::$db->tokenLookup("fever.login", "80fa94c1a11f11e78667001e673b2560")); + $this->assertArraySubset($exp2, Arsse::$db->tokenLookup("class.class", "da772f8fa13c11e78667001e673b2560")); + // token lookup should not check authorization + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertArraySubset($exp1, Arsse::$db->tokenLookup("fever.login", "80fa94c1a11f11e78667001e673b2560")); + } + + public function testLookUpAMissingToken() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->tokenLookup("class", "thisTokenDoesNotExist"); + } + + public function testLookUpAnExpiredToken() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->tokenLookup("fever.login", "27c6de8da13311e78667001e673b2560"); + } + + public function testLookUpATokenOfTheWrongClass() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->tokenLookup("some.class", "80fa94c1a11f11e78667001e673b2560"); + } + + public function testCreateAToken() { + $user = "jane.doe@example.com"; + $state = $this->primeExpectations($this->data, ['arsse_tokens' => ["id", "class", "expires", "user"]]); + $id = Arsse::$db->tokenCreate($user, "fever.login"); + $state['arsse_tokens']['rows'][] = [$id, "fever.login", null, $user]; + $this->compareExpectations($state); + $id = Arsse::$db->tokenCreate($user, "fever.login", null, new \DateTime("2020-01-01T00:00:00Z")); + $state['arsse_tokens']['rows'][] = [$id, "fever.login", "2020-01-01 00:00:00", $user]; + $this->compareExpectations($state); + Arsse::$db->tokenCreate($user, "fever.login", "token!", new \DateTime("2021-01-01T00:00:00Z")); + $state['arsse_tokens']['rows'][] = ["token!", "fever.login", "2021-01-01 00:00:00", $user]; + $this->compareExpectations($state); + } + + public function testCreateATokenWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->tokenCreate("fever.login", "jane.doe@example.com"); + } + + public function testRevokeAToken() { + $user = "jane.doe@example.com"; + $id = "80fa94c1a11f11e78667001e673b2560"; + $this->assertTrue(Arsse::$db->tokenRevoke($user, "fever.login", $id)); + $state = $this->primeExpectations($this->data, ['arsse_tokens' => ["id", "expires", "user"]]); + unset($state['arsse_tokens']['rows'][0]); + $this->compareExpectations($state); + // revoking a token which does not exist is not an error + $this->assertFalse(Arsse::$db->tokenRevoke($user, "fever.login", $id)); + } + + public function testRevokeAllTokens() { + $user = "jane.doe@example.com"; + $state = $this->primeExpectations($this->data, ['arsse_tokens' => ["id", "expires", "user"]]); + $this->assertTrue(Arsse::$db->tokenRevoke($user, "fever.login")); + unset($state['arsse_tokens']['rows'][0]); + unset($state['arsse_tokens']['rows'][1]); + $this->compareExpectations($state); + $this->assertTrue(Arsse::$db->tokenRevoke($user, "class.class")); + unset($state['arsse_tokens']['rows'][2]); + $this->compareExpectations($state); + // revoking tokens which do not exist is not an error + $this->assertFalse(Arsse::$db->tokenRevoke($user, "unknown.class")); + } + + public function testRevokeATokenWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->tokenRevoke("jane.doe@example.com", "fever.login"); + } +} diff --git a/tests/cases/Database/SeriesUser.php b/tests/cases/Database/SeriesUser.php index 49c324b..991577a 100644 --- a/tests/cases/Database/SeriesUser.php +++ b/tests/cases/Database/SeriesUser.php @@ -17,13 +17,11 @@ trait SeriesUser { 'columns' => [ 'id' => 'str', 'password' => 'str', - 'name' => 'str', - 'rights' => 'int', ], 'rows' => [ - ["admin@example.net", '$2y$10$PbcG2ZR3Z8TuPzM7aHTF8.v61dtCjzjK78gdZJcp4UePE8T9jEgBW', "Hard Lip Herbert", 100], // password is hash of "secret" - ["jane.doe@example.com", "", "Jane Doe", 0], - ["john.doe@example.com", "", "John Doe", 0], + ["admin@example.net", '$2y$10$PbcG2ZR3Z8TuPzM7aHTF8.v61dtCjzjK78gdZJcp4UePE8T9jEgBW'], // password is hash of "secret" + ["jane.doe@example.com", ""], + ["john.doe@example.com", ""], ], ], ]; @@ -68,8 +66,8 @@ trait SeriesUser { public function testAddANewUser() { $this->assertTrue(Arsse::$db->userAdd("john.doe@example.org", "")); Phake::verify(Arsse::$user)->authorize("john.doe@example.org", "userAdd"); - $state = $this->primeExpectations($this->data, ['arsse_users' => ['id','name','rights']]); - $state['arsse_users']['rows'][] = ["john.doe@example.org", null, 0]; + $state = $this->primeExpectations($this->data, ['arsse_users' => ['id']]); + $state['arsse_users']['rows'][] = ["john.doe@example.org"]; $this->compareExpectations($state); } From 3aa2b62d0266ff8c55d47ea77ada17cee8eb0cbb Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 9 Mar 2019 22:44:59 -0500 Subject: [PATCH 040/142] Basic Fever skeleton Authentication should work, but not tests have been written yet --- lib/REST.php | 12 +++--- lib/REST/Fever/API.php | 98 ++++++++++++++++++++++++++++++++++++++++++ lib/User.php | 2 +- 3 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 lib/REST/Fever/API.php diff --git a/lib/REST.php b/lib/REST.php index 39899c1..1ad740d 100644 --- a/lib/REST.php +++ b/lib/REST.php @@ -16,14 +16,12 @@ use Zend\Diactoros\Response\EmptyResponse; class REST { const API_LIST = [ - // NextCloud News version enumerator - 'ncn' => [ + 'ncn' => [ // NextCloud News version enumerator 'match' => '/index.php/apps/news/api', 'strip' => '/index.php/apps/news/api', 'class' => REST\NextCloudNews\Versions::class, ], - // NextCloud News v1-2 https://github.com/nextcloud/news/blob/master/docs/externalapi/Legacy.md - 'ncn_v1-2' => [ + 'ncn_v1-2' => [ // NextCloud News v1-2 https://github.com/nextcloud/news/blob/master/docs/externalapi/Legacy.md 'match' => '/index.php/apps/news/api/v1-2/', 'strip' => '/index.php/apps/news/api/v1-2', 'class' => REST\NextCloudNews\V1_2::class, @@ -38,9 +36,13 @@ class REST { 'strip' => '/tt-rss/feed-icons/', 'class' => REST\TinyTinyRSS\Icon::class, ], + 'fever' => [ // Fever https://web.archive.org/web/20161217042229/https://feedafever.com/api + 'match' => '/fever/', + 'strip' => '/fever/', + 'class' => REST\Fever\API::class, + ], // Other candidates: // Google Reader http://feedhq.readthedocs.io/en/latest/api/index.html - // Fever https://web.archive.org/web/20161217042229/https://feedafever.com/api // Feedbin v2 https://github.com/feedbin/feedbin-api // CommaFeed https://www.commafeed.com/api/ // Selfoss https://github.com/SSilence/selfoss/wiki/Restful-API-for-Apps-or-any-other-external-access diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php new file mode 100644 index 0000000..b9fd5ad --- /dev/null +++ b/lib/REST/Fever/API.php @@ -0,0 +1,98 @@ +getQueryParams(); + if (!array_key_exists("api")) { + // the original would have shown the Fever UI in the absence of the "api" parameter, but we'll return 404 + return new EmptyResponse(404); + } + $xml = $inR['api'] === "xml"; + switch ($req->getMethod()) { + case "OPTIONS": + // do stuff + break; + case "POST": + if (strlen($req->getHeaderLine("Content-Type")) && $req->getHeaderLine("Content-Type") !== "application/x-www-form-urlencoded") { + return new EmptyResponse(415, ['Accept' => "application/x-www-form-urlencoded"]); + } + $inW = $req->getParsedBody(); + $out = [ + 'api_version' => self::LEVEL, + 'auth' => 0, + ]; + // check that the user specified credentials + if ($this->logIn(strtolower($inW['api_key'] ?? ""))) { + $out['auth'] = 1; + } else { + return $this->formatResponse($out, $xml); + } + // handle each possible parameter + # do stuff + // return the result + return $this->formatResponse($out, $xml); + break; + default: + return new EmptyResponse(405, ['Allow' => "OPTIONS,POST"]); + } + } + + protected function formatResponse(array $data, bool $xml): ResponseInterface { + if ($xml) { + throw \Exception("Not implemented yet"); + } else { + return new JsonResponse($data, 200, [], \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); + } + } + + protected function logIn(string $hash): bool { + // if HTTP authentication was successful and sessions are not enforced, proceed unconditionally + if (isset(Arsse::$user->id) && !Arsse::$conf->userSessionEnforced) { + return true; + } + try { + // verify the supplied hash is valid + $s = Arsse::$db->TokenLookup($id, "fever.login"); + } catch (\JKingWeb\Arsse\Db\ExceptionInput $e) { + return false; + } + // set the user name + Arsse::$user->id = $s['user']; + return true; + } + + public static function registerUser(string $user, string $password = null): string { + $password = $password ?? Arsse::$user->generatePassword(); + $hash = md5("$user:$password"); + Arsse::$db->tokenCreate($user, "fever.login", $hash); + return $password; + } +} diff --git a/lib/User.php b/lib/User.php index d7aae1c..82e8d3d 100644 --- a/lib/User.php +++ b/lib/User.php @@ -114,7 +114,7 @@ class User { return $out; } - protected function generatePassword(): string { + public function generatePassword(): string { return (new PassGen)->length(Arsse::$conf->userTempPasswordLength)->get(); } } From b02c910b1e360b3287faad9c7b6ae7b4d34fa16b Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sun, 10 Mar 2019 15:54:43 -0400 Subject: [PATCH 041/142] Make token creation check that the user exists --- lib/Database.php | 2 ++ tests/cases/Database/SeriesToken.php | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/lib/Database.php b/lib/Database.php index 01cd91a..df614b5 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -392,6 +392,8 @@ class Database { // If the user isn't authorized to perform this action then throw an exception. if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } elseif (!$this->userExists($user)) { + throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); } // generate a token if it's not provided $id = $id ?? UUID::mint()->hex; diff --git a/tests/cases/Database/SeriesToken.php b/tests/cases/Database/SeriesToken.php index 738fc58..ff85407 100644 --- a/tests/cases/Database/SeriesToken.php +++ b/tests/cases/Database/SeriesToken.php @@ -96,6 +96,11 @@ trait SeriesToken { $this->compareExpectations($state); } + public function testCreateATokenForAMissingUser() { + $this->assertException("doesNotExist", "User"); + Arsse::$db->tokenCreate("fever.login", "jane.doe@example.biz"); + } + public function testCreateATokenWithoutAuthority() { Phake::when(Arsse::$user)->authorize->thenReturn(false); $this->assertException("notAuthorized", "User", "ExceptionAuthz"); From 86d52c8ff9a9538f017db776d2bf6c313979af32 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 16 Mar 2019 17:48:48 -0400 Subject: [PATCH 042/142] Fix test errors when PostgreSQL or MySQL are not available --- tests/cases/Database/Base.php | 10 ++++++---- tests/cases/DatabaseDrivers/MySQL.php | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/cases/Database/Base.php b/tests/cases/Database/Base.php index 219d4c0..92cf19c 100644 --- a/tests/cases/Database/Base.php +++ b/tests/cases/Database/Base.php @@ -100,10 +100,12 @@ abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest { } public static function tearDownAfterClass() { - // wipe the database absolutely clean - static::dbRaze(static::$drv); - // clean up - static::$drv = null; + if (static::$drv) { + // wipe the database absolutely clean + static::dbRaze(static::$drv); + // clean up + static::$drv = null; + } static::$failureReason = ""; static::clearData(); } diff --git a/tests/cases/DatabaseDrivers/MySQL.php b/tests/cases/DatabaseDrivers/MySQL.php index 27dcb4a..3d14d2e 100644 --- a/tests/cases/DatabaseDrivers/MySQL.php +++ b/tests/cases/DatabaseDrivers/MySQL.php @@ -18,7 +18,7 @@ trait MySQL { protected static $stringOutput = true; public static function dbInterface() { - $d = new \mysqli(Arsse::$conf->dbMySQLHost, Arsse::$conf->dbMySQLUser, Arsse::$conf->dbMySQLPass, Arsse::$conf->dbMySQLDb, Arsse::$conf->dbMySQLPort); + $d = @new \mysqli(Arsse::$conf->dbMySQLHost, Arsse::$conf->dbMySQLUser, Arsse::$conf->dbMySQLPass, Arsse::$conf->dbMySQLDb, Arsse::$conf->dbMySQLPort); if ($d->connect_errno) { return; } From d59223bbcb7336ab44b45cbd70b041c7338f97cb Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 18 Mar 2019 22:49:47 -0400 Subject: [PATCH 043/142] First authentication test for Fever --- lib/REST/Fever/API.php | 8 +- tests/cases/REST/Fever/PDO/TestAPI.php | 13 ++++ tests/cases/REST/Fever/TestAPI.php | 100 +++++++++++++++++++++++++ tests/phpunit.xml | 4 + 4 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 tests/cases/REST/Fever/PDO/TestAPI.php create mode 100644 tests/cases/REST/Fever/TestAPI.php diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index b9fd5ad..6effe27 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -20,7 +20,7 @@ use JKingWeb\Arsse\REST\Exception404; use JKingWeb\Arsse\REST\Exception405; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; -use Zend\Diactoros\Response\JsonResponse as Response; +use Zend\Diactoros\Response\JsonResponse; use Zend\Diactoros\Response\EmptyResponse; class API extends \JKingWeb\Arsse\REST\AbstractHandler { @@ -31,7 +31,8 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { public function dispatch(ServerRequestInterface $req): ResponseInterface { $inR = $req->getQueryParams(); - if (!array_key_exists("api")) { + $inW = $req->getParsedBody(); + if (!array_key_exists("api", $inR)) { // the original would have shown the Fever UI in the absence of the "api" parameter, but we'll return 404 return new EmptyResponse(404); } @@ -44,7 +45,6 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { if (strlen($req->getHeaderLine("Content-Type")) && $req->getHeaderLine("Content-Type") !== "application/x-www-form-urlencoded") { return new EmptyResponse(415, ['Accept' => "application/x-www-form-urlencoded"]); } - $inW = $req->getParsedBody(); $out = [ 'api_version' => self::LEVEL, 'auth' => 0, @@ -80,7 +80,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } try { // verify the supplied hash is valid - $s = Arsse::$db->TokenLookup($id, "fever.login"); + $s = Arsse::$db->TokenLookup("fever.login", $hash); } catch (\JKingWeb\Arsse\Db\ExceptionInput $e) { return false; } diff --git a/tests/cases/REST/Fever/PDO/TestAPI.php b/tests/cases/REST/Fever/PDO/TestAPI.php new file mode 100644 index 0000000..02caa3d --- /dev/null +++ b/tests/cases/REST/Fever/PDO/TestAPI.php @@ -0,0 +1,13 @@ + + * @group optional */ +class TestAPI extends \JKingWeb\Arsse\TestCase\REST\Fever\TestAPI { + use \JKingWeb\Arsse\Test\PDOTest; +} diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php new file mode 100644 index 0000000..54b3e07 --- /dev/null +++ b/tests/cases/REST/Fever/TestAPI.php @@ -0,0 +1,100 @@ + */ +class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { + + protected function v($value) { + return $value; + } + + protected function req($dataGet, $dataPost, string $method = "POST", string $type = null, string $url = "", string $user = null): ResponseInterface { + $url = "/fever/".$url; + $server = [ + 'REQUEST_METHOD' => $method, + 'REQUEST_URI' => $url, + 'HTTP_CONTENT_TYPE' => $type ?? "application/x-www-form-urlencoded", + ]; + $req = new ServerRequest($server, [], $url, $method, "php://memory"); + if (is_array($dataGet)) { + $req = $req->withRequestTarget($url)->withQueryParams($dataGet); + } else { + $req = $req->withRequestTarget($url."?".http_build_query((string) $dataGet, "", "&", \PHP_QUERY_RFC3986)); + } + if (is_array($dataPost)) { + $req = $req->withParsedBody($dataPost); + } else { + $body = $req->getBody(); + $body->write($strData); + $req = $req->withBody($body); + } + if (isset($user)) { + if (strlen($user)) { + $req = $req->withAttribute("authenticated", true)->withAttribute("authenticatedUser", $user); + } else { + $req = $req->withAttribute("authenticationFailed", true); + } + } + return $this->h->dispatch($req); + } + + public function setUp() { + self::clearData(); + self::setConf(); + // create a mock user manager + Arsse::$user = Phake::mock(User::class); + Phake::when(Arsse::$user)->auth->thenReturn(true); + Arsse::$user->id = "john.doe@example.com"; + // create a mock database interface + Arsse::$db = Phake::mock(Database::class); + Phake::when(Arsse::$db)->begin->thenReturn(Phake::mock(Transaction::class)); + // instantiate the handler + $this->h = new API(); + } + + public function tearDown() { + self::clearData(); + } + + /** @dataProvider provideAuthenticationRequests */ + public function testAuthenticateAUser(bool $httpRequired, bool $tokenEnforced, string $httpUser = null, array $dataPost, array $dataGet, bool $success) { + self::setConf([ + 'userHTTPAuthRequired' => $httpRequired, + 'userSessionEnforced' => $tokenEnforced, + ], true); + \Phake::when(Arsse::$db)->tokenLookup->thenThrow(new ExceptionInput("subjectMissing")); + \Phake::when(Arsse::$db)->tokenLookup("fever.login", "validtoken")->thenReturn(['user' => "jane.doe@example.com"]); + $exp = new JsonResponse($success ? ['api_version' => API::LEVEL, 'auth' => 1] : ['api_version' => API::LEVEL, 'auth' => 0]); + $act = $this->req($dataGet, $dataPost, "POST", null, "", $httpUser); + $this->assertMessage($exp, $act); + } + + public function provideAuthenticationRequests() { + return [ + [false, true, null, ['api_key' => "validToken"], ['api' => null], true], + ]; + } +} diff --git a/tests/phpunit.xml b/tests/phpunit.xml index aac033b..9200dd8 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -104,6 +104,10 @@ cases/REST/TinyTinyRSS/TestIcon.php cases/REST/TinyTinyRSS/PDO/TestAPI.php + + cases/REST/Fever/TestAPI.php + cases/REST/Fever/PDO/TestAPI.php + cases/Service/TestService.php cases/CLI/TestCLI.php From 1e2d595992da5346b496c2c2be234991f0febdbb Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 19 Mar 2019 23:37:08 -0400 Subject: [PATCH 044/142] Full set of authentication tests for Fever --- lib/REST/Fever/API.php | 9 ++++++ tests/cases/REST/Fever/TestAPI.php | 44 ++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 6effe27..30665ab 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -49,10 +49,19 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 'api_version' => self::LEVEL, 'auth' => 0, ]; + if ($req->getAttribute("authenticated", false)) { + // if HTTP authentication was successfully used, set the expected user ID + Arsse::$user->id = $req->getAttribute("authenticatedUser"); + $out['auth'] = 1; + } elseif (Arsse::$conf->userHTTPAuthRequired || Arsse::$conf->userPreAuth || $req->getAttribute("authenticationFailed", false)) { + // otherwise if HTTP authentication failed or is required, deny access at the HTTP level + return new EmptyResponse(401); + } // check that the user specified credentials if ($this->logIn(strtolower($inW['api_key'] ?? ""))) { $out['auth'] = 1; } else { + $out['auth'] = 0; return $this->formatResponse($out, $xml); } // handle each possible parameter diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index 54b3e07..ccf4564 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -80,21 +80,59 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { } /** @dataProvider provideAuthenticationRequests */ - public function testAuthenticateAUser(bool $httpRequired, bool $tokenEnforced, string $httpUser = null, array $dataPost, array $dataGet, bool $success) { + public function testAuthenticateAUser(bool $httpRequired, bool $tokenEnforced, string $httpUser = null, array $dataPost, array $dataGet, ResponseInterface $exp) { self::setConf([ 'userHTTPAuthRequired' => $httpRequired, 'userSessionEnforced' => $tokenEnforced, ], true); + Arsse::$user->id = null; \Phake::when(Arsse::$db)->tokenLookup->thenThrow(new ExceptionInput("subjectMissing")); \Phake::when(Arsse::$db)->tokenLookup("fever.login", "validtoken")->thenReturn(['user' => "jane.doe@example.com"]); - $exp = new JsonResponse($success ? ['api_version' => API::LEVEL, 'auth' => 1] : ['api_version' => API::LEVEL, 'auth' => 0]); $act = $this->req($dataGet, $dataPost, "POST", null, "", $httpUser); $this->assertMessage($exp, $act); } public function provideAuthenticationRequests() { + $success = new JsonResponse(['api_version' => API::LEVEL, 'auth' => 1]); + $failure = new JsonResponse(['api_version' => API::LEVEL, 'auth' => 0]); + $denied = new EmptyResponse(401); return [ - [false, true, null, ['api_key' => "validToken"], ['api' => null], true], + [false, true, null, [], ['api' => null], $failure], + [false, false, null, [], ['api' => null], $failure], + [true, true, null, [], ['api' => null], $denied], + [true, false, null, [], ['api' => null], $denied], + [false, true, "", [], ['api' => null], $denied], + [false, false, "", [], ['api' => null], $denied], + [true, true, "", [], ['api' => null], $denied], + [true, false, "", [], ['api' => null], $denied], + [false, true, null, [], ['api' => null, 'api_key' => "validToken"], $failure], + [false, false, null, [], ['api' => null, 'api_key' => "validToken"], $failure], + [true, true, null, [], ['api' => null, 'api_key' => "validToken"], $denied], + [true, false, null, [], ['api' => null, 'api_key' => "validToken"], $denied], + [false, true, null, ['api_key' => "validToken"], ['api' => null], $success], + [false, false, null, ['api_key' => "validToken"], ['api' => null], $success], + [true, true, null, ['api_key' => "validToken"], ['api' => null], $denied], + [true, false, null, ['api_key' => "validToken"], ['api' => null], $denied], + [false, true, "", ['api_key' => "validToken"], ['api' => null], $denied], + [false, false, "", ['api_key' => "validToken"], ['api' => null], $denied], + [true, true, "", ['api_key' => "validToken"], ['api' => null], $denied], + [true, false, "", ['api_key' => "validToken"], ['api' => null], $denied], + [false, true, "validUser", ['api_key' => "validToken"], ['api' => null], $success], + [false, false, "validUser", ['api_key' => "validToken"], ['api' => null], $success], + [true, true, "validUser", ['api_key' => "validToken"], ['api' => null], $success], + [true, false, "validUser", ['api_key' => "validToken"], ['api' => null], $success], + [false, true, null, ['api_key' => "invalidToken"], ['api' => null], $failure], + [false, false, null, ['api_key' => "invalidToken"], ['api' => null], $failure], + [true, true, null, ['api_key' => "invalidToken"], ['api' => null], $denied], + [true, false, null, ['api_key' => "invalidToken"], ['api' => null], $denied], + [false, true, "", ['api_key' => "invalidToken"], ['api' => null], $denied], + [false, false, "", ['api_key' => "invalidToken"], ['api' => null], $denied], + [true, true, "", ['api_key' => "invalidToken"], ['api' => null], $denied], + [true, false, "", ['api_key' => "invalidToken"], ['api' => null], $denied], + [false, true, "validUser", ['api_key' => "invalidToken"], ['api' => null], $failure], + [false, false, "validUser", ['api_key' => "invalidToken"], ['api' => null], $success], + [true, true, "validUser", ['api_key' => "invalidToken"], ['api' => null], $failure], + [true, false, "validUser", ['api_key' => "invalidToken"], ['api' => null], $success], ]; } } From 91681552442694859b93e65725ff113b5e51640c Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 20 Mar 2019 10:42:04 -0400 Subject: [PATCH 045/142] Add method to unset a Fever password --- lib/REST/Fever/API.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 30665ab..6585438 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -104,4 +104,8 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { Arsse::$db->tokenCreate($user, "fever.login", $hash); return $password; } + + public static function unregisterUser(string $user): bool { + return (bool) Arsse::$db->tokenRevoke($user, "fever.login"); + } } From 9ebaa206337c282f6ea4e768e11a6a17cac36a4a Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 20 Mar 2019 22:24:35 -0400 Subject: [PATCH 046/142] Tests for Fever password creation and removal --- lib/REST/Fever/API.php | 3 +++ tests/cases/REST/Fever/TestAPI.php | 40 +++++++++++++++++++++++++++++- tests/lib/AbstractTest.php | 21 ++++++++++------ 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 6585438..b5b91ab 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -101,7 +101,10 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { public static function registerUser(string $user, string $password = null): string { $password = $password ?? Arsse::$user->generatePassword(); $hash = md5("$user:$password"); + $tr = Arsse::$db->begin(); + Arsse::$db->tokenRevoke($user, "fever.login"); Arsse::$db->tokenCreate($user, "fever.login", $hash); + $tr->commit(); return $password; } diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index ccf4564..0d974d1 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -16,6 +16,7 @@ use JKingWeb\Arsse\Test\Result; use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Db\ExceptionInput; +use JKingWeb\Arsse\User\Exception as UserException; use JKingWeb\Arsse\Db\Transaction; use JKingWeb\Arsse\REST\Fever\API; use Psr\Http\Message\ResponseInterface; @@ -48,7 +49,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { $req = $req->withParsedBody($dataPost); } else { $body = $req->getBody(); - $body->write($strData); + $body->write($dataPost); $req = $req->withBody($body); } if (isset($user)) { @@ -135,4 +136,41 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { [true, false, "validUser", ['api_key' => "invalidToken"], ['api' => null], $success], ]; } + + /** @dataProvider providePasswordCreations */ + public function testRegisterAUserPassword(string $user, string $password = null, $exp) { + \Phake::when(Arsse::$user)->generatePassword->thenReturn("RANDOM_PASSWORD"); + \Phake::when(Arsse::$db)->tokenCreate->thenReturnCallback(function($user, $class, $id = null) { + return $id ?? "RANDOM_TOKEN"; + }); + \Phake::when(Arsse::$db)->tokenCreate("john.doe@example.org", $this->anything(), $this->anything())->thenThrow(new UserException("doesNotExist")); + if ($exp instanceof \JKingWeb\Arsse\AbstractException) { + $this->assertException($exp); + API::registerUser($user, $password); + } else { + $this->assertSame($exp, API::registerUser($user, $password)); + } + \Phake::verify(Arsse::$db)->tokenRevoke($user, "fever.login"); + \Phake::verify(Arsse::$db)->tokenCreate($user, "fever.login", md5($user.":".($password ?? "RANDOM_PASSWORD"))); + } + + public function providePasswordCreations() { + return [ + ["jane.doe@example.com", "secret", "secret"], + ["jane.doe@example.com", "superman", "superman"], + ["jane.doe@example.com", null, "RANDOM_PASSWORD"], + ["john.doe@example.org", null, new UserException("doesNotExist")], + ["john.doe@example.net", null, "RANDOM_PASSWORD"], + ["john.doe@example.net", "secret", "secret"], + ]; + } + + public function testUnregisterAUser() { + \Phake::when(Arsse::$db)->tokenRevoke->thenReturn(3); + $this->assertTrue(API::unregisterUser("jane.doe@example.com")); + \Phake::verify(Arsse::$db)->tokenRevoke("jane.doe@example.com", "fever.login"); + \Phake::when(Arsse::$db)->tokenRevoke->thenReturn(0); + $this->assertFalse(API::unregisterUser("john.doe@example.com")); + \Phake::verify(Arsse::$db)->tokenRevoke("john.doe@example.com", "fever.login"); + } } diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php index 3814221..f55ca9b 100644 --- a/tests/lib/AbstractTest.php +++ b/tests/lib/AbstractTest.php @@ -55,17 +55,22 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { Arsse::$conf = (($force ? null : Arsse::$conf) ?? (new Conf))->import($defaults)->import($conf); } - public function assertException(string $msg = "", string $prefix = "", string $type = "Exception") { + public function assertException($msg = "", string $prefix = "", string $type = "Exception") { if (func_num_args()) { - $class = \JKingWeb\Arsse\NS_BASE . ($prefix !== "" ? str_replace("/", "\\", $prefix) . "\\" : "") . $type; - $msgID = ($prefix !== "" ? $prefix . "/" : "") . $type. ".$msg"; - if (array_key_exists($msgID, Exception::CODES)) { - $code = Exception::CODES[$msgID]; + if ($msg instanceof \JKingWeb\Arsse\AbstractException) { + $this->expectException(get_class($msg)); + $this->expectExceptionCode($msg->getCode()); } else { - $code = 0; + $class = \JKingWeb\Arsse\NS_BASE . ($prefix !== "" ? str_replace("/", "\\", $prefix) . "\\" : "") . $type; + $msgID = ($prefix !== "" ? $prefix . "/" : "") . $type. ".$msg"; + if (array_key_exists($msgID, Exception::CODES)) { + $code = Exception::CODES[$msgID]; + } else { + $code = 0; + } + $this->expectException($class); + $this->expectExceptionCode($code); } - $this->expectException($class); - $this->expectExceptionCode($code); } else { // expecting a standard PHP exception $this->expectException(\Throwable::class); From f51d20a863078f70ae09b0714f62fa261f0b302e Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 20 Mar 2019 22:25:00 -0400 Subject: [PATCH 047/142] Unix Robo fixes --- RoboFile.php | 2 +- robo | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/RoboFile.php b/RoboFile.php index e73d3e4..8df789b 100644 --- a/RoboFile.php +++ b/RoboFile.php @@ -85,7 +85,7 @@ class RoboFile extends \Robo\Tasks { $dbg = dirname(\PHP_BINARY)."\\phpdbg.exe"; $dbg = file_exists($dbg) ? $dbg : ""; } else { - $dbg = `which phpdbg`; + $dbg = trim(`which phpdbg`); } if ($dbg) { return escapeshellarg($dbg)." -qrr"; diff --git a/robo b/robo index 7d4d4d7..d6af8df 100755 --- a/robo +++ b/robo @@ -3,8 +3,8 @@ base=`dirname "$0"` roboCommand="$1" shift -if [ "$1" == "clean" ]; then +if [ "$1" = "clean" ]; then "$base/vendor/bin/robo" "$roboCommand" $* else "$base/vendor/bin/robo" "$roboCommand" -- $* -fi \ No newline at end of file +fi From 5480b59d934e0cbab48754df98bd71280560ce5c Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 20 Mar 2019 22:25:00 -0400 Subject: [PATCH 048/142] Unix Robo fixes --- RoboFile.php | 2 +- robo | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/RoboFile.php b/RoboFile.php index e73d3e4..8df789b 100644 --- a/RoboFile.php +++ b/RoboFile.php @@ -85,7 +85,7 @@ class RoboFile extends \Robo\Tasks { $dbg = dirname(\PHP_BINARY)."\\phpdbg.exe"; $dbg = file_exists($dbg) ? $dbg : ""; } else { - $dbg = `which phpdbg`; + $dbg = trim(`which phpdbg`); } if ($dbg) { return escapeshellarg($dbg)." -qrr"; diff --git a/robo b/robo index 7d4d4d7..d6af8df 100755 --- a/robo +++ b/robo @@ -3,8 +3,8 @@ base=`dirname "$0"` roboCommand="$1" shift -if [ "$1" == "clean" ]; then +if [ "$1" = "clean" ]; then "$base/vendor/bin/robo" "$roboCommand" $* else "$base/vendor/bin/robo" "$roboCommand" -- $* -fi \ No newline at end of file +fi From 07122b524a3a6bbb5d4dba3e5d89f26177264a9d Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 21 Mar 2019 10:19:30 -0400 Subject: [PATCH 049/142] Rename Fever user functions for consistency --- lib/REST/Fever/API.php | 4 ++-- tests/cases/REST/Fever/TestAPI.php | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index b5b91ab..1c0c669 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -98,7 +98,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { return true; } - public static function registerUser(string $user, string $password = null): string { + public static function userRegister(string $user, string $password = null): string { $password = $password ?? Arsse::$user->generatePassword(); $hash = md5("$user:$password"); $tr = Arsse::$db->begin(); @@ -108,7 +108,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { return $password; } - public static function unregisterUser(string $user): bool { + public static function userUnregister(string $user): bool { return (bool) Arsse::$db->tokenRevoke($user, "fever.login"); } } diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index 0d974d1..8d7867a 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -146,9 +146,9 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { \Phake::when(Arsse::$db)->tokenCreate("john.doe@example.org", $this->anything(), $this->anything())->thenThrow(new UserException("doesNotExist")); if ($exp instanceof \JKingWeb\Arsse\AbstractException) { $this->assertException($exp); - API::registerUser($user, $password); + API::userRegister($user, $password); } else { - $this->assertSame($exp, API::registerUser($user, $password)); + $this->assertSame($exp, API::userRegister($user, $password)); } \Phake::verify(Arsse::$db)->tokenRevoke($user, "fever.login"); \Phake::verify(Arsse::$db)->tokenCreate($user, "fever.login", md5($user.":".($password ?? "RANDOM_PASSWORD"))); @@ -167,10 +167,10 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { public function testUnregisterAUser() { \Phake::when(Arsse::$db)->tokenRevoke->thenReturn(3); - $this->assertTrue(API::unregisterUser("jane.doe@example.com")); + $this->assertTrue(API::userUnregister("jane.doe@example.com")); \Phake::verify(Arsse::$db)->tokenRevoke("jane.doe@example.com", "fever.login"); \Phake::when(Arsse::$db)->tokenRevoke->thenReturn(0); - $this->assertFalse(API::unregisterUser("john.doe@example.com")); + $this->assertFalse(API::userUnregister("john.doe@example.com")); \Phake::verify(Arsse::$db)->tokenRevoke("john.doe@example.com", "fever.login"); } } From 3b28634447e4eb959ebef75c7e11351e6f68e7d0 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 21 Mar 2019 11:00:07 -0400 Subject: [PATCH 050/142] Verify even in exceptional cases --- tests/cases/REST/Fever/TestAPI.php | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index 8d7867a..89254bf 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -81,7 +81,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { } /** @dataProvider provideAuthenticationRequests */ - public function testAuthenticateAUser(bool $httpRequired, bool $tokenEnforced, string $httpUser = null, array $dataPost, array $dataGet, ResponseInterface $exp) { + public function testAuthenticateAUserToken(bool $httpRequired, bool $tokenEnforced, string $httpUser = null, array $dataPost, array $dataGet, ResponseInterface $exp) { self::setConf([ 'userHTTPAuthRequired' => $httpRequired, 'userSessionEnforced' => $tokenEnforced, @@ -144,14 +144,17 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { return $id ?? "RANDOM_TOKEN"; }); \Phake::when(Arsse::$db)->tokenCreate("john.doe@example.org", $this->anything(), $this->anything())->thenThrow(new UserException("doesNotExist")); - if ($exp instanceof \JKingWeb\Arsse\AbstractException) { - $this->assertException($exp); - API::userRegister($user, $password); - } else { - $this->assertSame($exp, API::userRegister($user, $password)); + try { + if ($exp instanceof \JKingWeb\Arsse\AbstractException) { + $this->assertException($exp); + API::userRegister($user, $password); + } else { + $this->assertSame($exp, API::userRegister($user, $password)); + } + } finally { + \Phake::verify(Arsse::$db)->tokenRevoke($user, "fever.login"); + \Phake::verify(Arsse::$db)->tokenCreate($user, "fever.login", md5($user.":".($password ?? "RANDOM_PASSWORD"))); } - \Phake::verify(Arsse::$db)->tokenRevoke($user, "fever.login"); - \Phake::verify(Arsse::$db)->tokenCreate($user, "fever.login", md5($user.":".($password ?? "RANDOM_PASSWORD"))); } public function providePasswordCreations() { From fe008d4343bcafd7dde410c3c6bc452377afe89d Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 21 Mar 2019 13:49:55 -0400 Subject: [PATCH 051/142] A few more Fever authentication tests --- tests/cases/REST/Fever/TestAPI.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index 89254bf..08d2f89 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -110,6 +110,14 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { [false, false, null, [], ['api' => null, 'api_key' => "validToken"], $failure], [true, true, null, [], ['api' => null, 'api_key' => "validToken"], $denied], [true, false, null, [], ['api' => null, 'api_key' => "validToken"], $denied], + [false, true, "", [], ['api' => null, 'api_key' => "validToken"], $denied], + [false, false, "", [], ['api' => null, 'api_key' => "validToken"], $denied], + [true, true, "", [], ['api' => null, 'api_key' => "validToken"], $denied], + [true, false, "", [], ['api' => null, 'api_key' => "validToken"], $denied], + [false, true, "validUser", [], ['api' => null, 'api_key' => "validToken"], $failure], + [false, false, "validUser", [], ['api' => null, 'api_key' => "validToken"], $success], + [true, true, "validUser", [], ['api' => null, 'api_key' => "validToken"], $failure], + [true, false, "validUser", [], ['api' => null, 'api_key' => "validToken"], $success], [false, true, null, ['api_key' => "validToken"], ['api' => null], $success], [false, false, null, ['api_key' => "validToken"], ['api' => null], $success], [true, true, null, ['api_key' => "validToken"], ['api' => null], $denied], From 94314f3e6d6cccdc7c1fc4d426a34ebcd37196d2 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 16 Mar 2019 17:48:48 -0400 Subject: [PATCH 052/142] Fix test errors when PostgreSQL or MySQL are not available --- tests/cases/Database/Base.php | 10 ++++++---- tests/cases/DatabaseDrivers/MySQL.php | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/cases/Database/Base.php b/tests/cases/Database/Base.php index 47803ff..de6d39e 100644 --- a/tests/cases/Database/Base.php +++ b/tests/cases/Database/Base.php @@ -102,10 +102,12 @@ abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest { } public static function tearDownAfterClass() { - // wipe the database absolutely clean - static::dbRaze(static::$drv); - // clean up - static::$drv = null; + if (static::$drv) { + // wipe the database absolutely clean + static::dbRaze(static::$drv); + // clean up + static::$drv = null; + } static::$failureReason = ""; static::clearData(); } diff --git a/tests/cases/DatabaseDrivers/MySQL.php b/tests/cases/DatabaseDrivers/MySQL.php index 27dcb4a..3d14d2e 100644 --- a/tests/cases/DatabaseDrivers/MySQL.php +++ b/tests/cases/DatabaseDrivers/MySQL.php @@ -18,7 +18,7 @@ trait MySQL { protected static $stringOutput = true; public static function dbInterface() { - $d = new \mysqli(Arsse::$conf->dbMySQLHost, Arsse::$conf->dbMySQLUser, Arsse::$conf->dbMySQLPass, Arsse::$conf->dbMySQLDb, Arsse::$conf->dbMySQLPort); + $d = @new \mysqli(Arsse::$conf->dbMySQLHost, Arsse::$conf->dbMySQLUser, Arsse::$conf->dbMySQLPass, Arsse::$conf->dbMySQLDb, Arsse::$conf->dbMySQLPort); if ($d->connect_errno) { return; } From 5bf0b67ec3e5d77c9a7946799c176c8070c3f0ad Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sun, 24 Mar 2019 14:41:17 -0400 Subject: [PATCH 053/142] Increase file descriptor limit for Robo on Linux --- robo | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/robo b/robo index d6af8df..0b3be08 100755 --- a/robo +++ b/robo @@ -1,8 +1,9 @@ #! /bin/sh base=`dirname "$0"` roboCommand="$1" - shift + +ulimit -n 2048 if [ "$1" = "clean" ]; then "$base/vendor/bin/robo" "$roboCommand" $* else From e45ba3f0eaa398a159eeac278d9491c6ad11f08a Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sun, 24 Mar 2019 14:42:23 -0400 Subject: [PATCH 054/142] Add means of unsetting a password in the backend --- lib/Database.php | 10 +++--- lib/User.php | 13 ++++++++ lib/User/Driver.php | 2 ++ lib/User/Internal/Driver.php | 15 ++++++++- tests/cases/Database/SeriesUser.php | 7 ++++ tests/cases/User/TestInternal.php | 52 ++++++++++++++++++++--------- tests/cases/User/TestUser.php | 38 +++++++++++++++++++++ 7 files changed, 115 insertions(+), 22 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index df614b5..bfbcfee 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -289,27 +289,27 @@ class Database { } /** Retrieves the hashed password of a user */ - public function userPasswordGet(string $user): string { + public function userPasswordGet(string $user) { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } elseif (!$this->userExists($user)) { throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); } - return (string) $this->db->prepare("SELECT password from arsse_users where id = ?", "str")->run($user)->getValue(); + return $this->db->prepare("SELECT password from arsse_users where id = ?", "str")->run($user)->getValue(); } /** Sets the password of an existing user * * @param string $user The user for whom to set the password - * @param string $password The new password, in cleartext. The password will be stored hashed + * @param string $password The new password, in cleartext. The password will be stored hashed. If null is passed, the password is unset and authentication not possible */ - public function userPasswordSet(string $user, string $password): bool { + public function userPasswordSet(string $user, string $password = null): bool { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } elseif (!$this->userExists($user)) { throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); } - $hash = (strlen($password) > 0) ? password_hash($password, \PASSWORD_DEFAULT) : ""; + $hash = (strlen($password ?? "") > 0) ? password_hash($password, \PASSWORD_DEFAULT) : $password; $this->db->prepare("UPDATE arsse_users set password = ? where id = ?", "str", "str")->run($hash, $user); return true; } diff --git a/lib/User.php b/lib/User.php index 82e8d3d..4f52980 100644 --- a/lib/User.php +++ b/lib/User.php @@ -114,6 +114,19 @@ class User { return $out; } + public function passwordUnset(string $user, $oldPassword = null): bool { + $func = "userPasswordUnset"; + if (!$this->authorize($user, $func)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => $user]); + } + $out = $this->u->userPasswordUnset($user, $oldPassword); + if (Arsse::$db->userExists($user)) { + // if the password change was successful and the user exists, set the internal password to the same value + Arsse::$db->userPasswordSet($user, null); + } + return $out; + } + public function generatePassword(): string { return (new PassGen)->length(Arsse::$conf->userTempPasswordLength)->get(); } diff --git a/lib/User/Driver.php b/lib/User/Driver.php index 50ef8f3..b5657ac 100644 --- a/lib/User/Driver.php +++ b/lib/User/Driver.php @@ -29,4 +29,6 @@ interface Driver { public function userList(): array; // sets a user's password; if the driver does not require the old password, it may be ignored public function userPasswordSet(string $user, string $newPassword = null, string $oldPassword = null); + // removes a user's password; this makes authentication fail unconditionally + public function userPasswordUnset(string $user, string $oldPassword = null): bool; } diff --git a/lib/User/Internal/Driver.php b/lib/User/Internal/Driver.php index 4c73025..d50777a 100644 --- a/lib/User/Internal/Driver.php +++ b/lib/User/Internal/Driver.php @@ -20,6 +20,9 @@ class Driver implements \JKingWeb\Arsse\User\Driver { public function auth(string $user, string $password): bool { try { $hash = $this->userPasswordGet($user); + if (is_null($hash)) { + return false; + } } catch (Exception $e) { return false; } @@ -58,7 +61,17 @@ class Driver implements \JKingWeb\Arsse\User\Driver { return $newPassword; } - protected function userPasswordGet(string $user): string { + public function userPasswordUnset(string $user, string $oldPassword = null): bool { + // do nothing: the internal database is updated regardless of what the driver does (assuming it does not throw an exception) + // throw an exception if the user does not exist + if (!$this->userExists($user)) { + throw new Exception("doesNotExist", ['action' => "userPasswordUnset", 'user' => $user]); + } else { + return true; + } + } + + protected function userPasswordGet(string $user) { return Arsse::$db->userPasswordGet($user); } } diff --git a/tests/cases/Database/SeriesUser.php b/tests/cases/Database/SeriesUser.php index 991577a..8036bee 100644 --- a/tests/cases/Database/SeriesUser.php +++ b/tests/cases/Database/SeriesUser.php @@ -127,6 +127,13 @@ trait SeriesUser { $this->assertTrue(password_verify($pass, $hash), "Failed verifying password of $user '$pass' against hash '$hash'."); } + public function testUnsetAPassword() { + $user = "john.doe@example.com"; + $this->assertEquals("", Arsse::$db->userPasswordGet($user)); + $this->assertTrue(Arsse::$db->userPasswordSet($user, null)); + $this->assertNull(Arsse::$db->userPasswordGet($user)); + } + public function testSetThePasswordOfAMissingUser() { $this->assertException("doesNotExist", "User"); Arsse::$db->userPasswordSet("john.doe@example.org", "secret"); diff --git a/tests/cases/User/TestInternal.php b/tests/cases/User/TestInternal.php index f7f042d..29d9923 100644 --- a/tests/cases/User/TestInternal.php +++ b/tests/cases/User/TestInternal.php @@ -37,12 +37,13 @@ class TestInternal extends \JKingWeb\Arsse\Test\AbstractTest { * @dataProvider provideAuthentication * @group slow */ - public function testAuthenticateAUser(bool $authorized, string $user, string $password, bool $exp) { + public function testAuthenticateAUser(bool $authorized, string $user, $password, bool $exp) { if ($authorized) { Phake::when(Arsse::$db)->userPasswordGet("john.doe@example.com")->thenReturn('$2y$10$1zbqRJhxM8uUjeSBPp4IhO90xrqK0XjEh9Z16iIYEFRV4U.zeAFom'); // hash of "secret" Phake::when(Arsse::$db)->userPasswordGet("jane.doe@example.com")->thenReturn('$2y$10$bK1ljXfTSyc2D.NYvT.Eq..OpehLRXVbglW.23ihVuyhgwJCd.7Im'); // hash of "superman" Phake::when(Arsse::$db)->userPasswordGet("owen.hardy@example.com")->thenReturn(""); Phake::when(Arsse::$db)->userPasswordGet("kira.nerys@example.com")->thenThrow(new \JKingWeb\Arsse\User\Exception("doesNotExist")); + Phake::when(Arsse::$db)->userPasswordGet("007@example.com")->thenReturn(null); } else { Phake::when(Arsse::$db)->userPasswordGet->thenThrow(new \JKingWeb\Arsse\User\ExceptionAuthz("notAuthorized")); } @@ -54,22 +55,26 @@ class TestInternal extends \JKingWeb\Arsse\Test\AbstractTest { $jane = "jane.doe@example.com"; $owen = "owen.hardy@example.com"; $kira = "kira.nerys@example.com"; + $bond = "007@example.com"; return [ - [false, $john, "secret", false], - [false, $jane, "superman", false], - [false, $owen, "", false], - [false, $kira, "ashalla", false], - [true, $john, "secret", true], - [true, $jane, "superman", true], - [true, $owen, "", true], - [true, $kira, "ashalla", false], - [true, $john, "top secret", false], - [true, $jane, "clark kent", false], - [true, $owen, "watchmaker", false], - [true, $kira, "singha", false], - [true, $john, "", false], - [true, $jane, "", false], - [true, $kira, "", false], + [false, $john, "secret", false], + [false, $jane, "superman", false], + [false, $owen, "", false], + [false, $kira, "ashalla", false], + [false, $bond, "", false], + [true, $john, "secret", true], + [true, $jane, "superman", true], + [true, $owen, "", true], + [true, $kira, "ashalla", false], + [true, $john, "top secret", false], + [true, $jane, "clark kent", false], + [true, $owen, "watchmaker", false], + [true, $kira, "singha", false], + [true, $john, "", false], + [true, $jane, "", false], + [true, $kira, "", false], + [true, $bond, "for England", false], + [true, $bond, "", false], ]; } @@ -133,4 +138,19 @@ class TestInternal extends \JKingWeb\Arsse\Test\AbstractTest { $this->assertSame("superman", (new Driver)->userPasswordSet($john, "superman")); $this->assertSame(null, (new Driver)->userPasswordSet($john, null)); } + + public function testUnsetAPassword() { + $drv = \Phake::partialMock(Driver::class); + \Phake::when($drv)->userExists->thenReturn(true); + Phake::verifyNoFurtherInteraction(Arsse::$db); + $this->assertTrue($drv->userPasswordUnset("john.doe@example.com")); + } + + public function testUnsetAPasswordForAMssingUser() { + $drv = \Phake::partialMock(Driver::class); + \Phake::when($drv)->userExists->thenReturn(false); + Phake::verifyNoFurtherInteraction(Arsse::$db); + $this->assertException("doesNotExist", "User"); + $drv->userPasswordUnset("john.doe@example.com"); + } } diff --git a/tests/cases/User/TestUser.php b/tests/cases/User/TestUser.php index 9496c41..3584f1e 100644 --- a/tests/cases/User/TestUser.php +++ b/tests/cases/User/TestUser.php @@ -297,4 +297,42 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest { [true, $jane, "secret", true, new \JKingWeb\Arsse\User\Exception("doesNotExist")], ]; } + + /** @dataProvider providePasswordClearings */ + public function testClearAPassword(bool $authorized, bool $exists, string $user, $exp) { + Phake::when($this->drv)->authorize->thenReturn($authorized); + Phake::when($this->drv)->userPasswordUnset->thenReturn(true); + Phake::when($this->drv)->userPasswordUnset("jane.doe@example.net", null)->thenThrow(new \JKingWeb\Arsse\User\Exception("doesNotExist")); + Phake::when(Arsse::$db)->userExists->thenReturn($exists); + $u = new User($this->drv); + try { + if ($exp instanceof \JKingWeb\Arsse\AbstractException) { + $this->assertException($exp); + $u->passwordUnset($user); + } else { + $this->assertSame($exp, $u->passwordUnset($user)); + } + } finally { + Phake::verify(Arsse::$db, Phake::times((int) ($authorized && $exists && is_bool($exp))))->userPasswordSet($user, null); + } + } + + public function providePasswordClearings() { + $forbidden = new \JKingWeb\Arsse\User\ExceptionAuthz("notAuthorized"); + $missing = new \JKingWeb\Arsse\User\Exception("doesNotExist"); + return [ + [false, true, "jane.doe@example.com", $forbidden], + [false, true, "john.doe@example.com", $forbidden], + [false, true, "jane.doe@example.net", $forbidden], + [false, false, "jane.doe@example.com", $forbidden], + [false, false, "john.doe@example.com", $forbidden], + [false, false, "jane.doe@example.net", $forbidden], + [true, true, "jane.doe@example.com", true], + [true, true, "john.doe@example.com", true], + [true, true, "jane.doe@example.net", $missing], + [true, false, "jane.doe@example.com", true], + [true, false, "john.doe@example.com", true], + [true, false, "jane.doe@example.net", $missing], + ]; + } } From 1ce95ef4d9324e8503fc7c55e48186a46ccbffa1 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sun, 24 Mar 2019 15:05:21 -0400 Subject: [PATCH 055/142] Add means of testing Fever authentication --- lib/REST/Fever/API.php | 8 ++++++++ tests/cases/REST/Fever/TestAPI.php | 21 +++++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 1c0c669..b80fe8d 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -111,4 +111,12 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { public static function userUnregister(string $user): bool { return (bool) Arsse::$db->tokenRevoke($user, "fever.login"); } + + public static function userAuthenticate(string $user, string $password): bool { + try { + return (bool) Arsse::$db->tokenLookup("fever.login", md5("$user:$password")); + } catch (ExceptionInput $e) { + return false; + } + } } diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index 08d2f89..b1c6901 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -80,7 +80,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { self::clearData(); } - /** @dataProvider provideAuthenticationRequests */ + /** @dataProvider provideTokenAuthenticationRequests */ public function testAuthenticateAUserToken(bool $httpRequired, bool $tokenEnforced, string $httpUser = null, array $dataPost, array $dataGet, ResponseInterface $exp) { self::setConf([ 'userHTTPAuthRequired' => $httpRequired, @@ -93,7 +93,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { $this->assertMessage($exp, $act); } - public function provideAuthenticationRequests() { + public function provideTokenAuthenticationRequests() { $success = new JsonResponse(['api_version' => API::LEVEL, 'auth' => 1]); $failure = new JsonResponse(['api_version' => API::LEVEL, 'auth' => 0]); $denied = new EmptyResponse(401); @@ -184,4 +184,21 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { $this->assertFalse(API::userUnregister("john.doe@example.com")); \Phake::verify(Arsse::$db)->tokenRevoke("john.doe@example.com", "fever.login"); } + + /** @dataProvider provideUserAuthenticationRequests */ + public function testAuthenticateAUserName(string $user, string $password, bool $exp) { + \Phake::when(Arsse::$db)->tokenLookup->thenThrow(new ExceptionInput("constraintViolation")); + \Phake::when(Arsse::$db)->tokenLookup("fever.login", md5("jane.doe@example.com:secret"))->thenReturn(['user' => "jane.doe@example.com"]); + \Phake::when(Arsse::$db)->tokenLookup("fever.login", md5("john.doe@example.com:superman"))->thenReturn(['user' => "john.doe@example.com"]); + $this->assertSame($exp, API::userAuthenticate($user, $password)); + } + + public function provideUserAuthenticationRequests() { + return [ + ["jane.doe@example.com", "secret", true], + ["jane.doe@example.com", "superman", false], + ["john.doe@example.com", "secret", false], + ["john.doe@example.com", "superman", true], + ]; + } } From 7d95e8fc0956a27993f801970d082fa026c0c334 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Mar 2019 08:31:49 -0400 Subject: [PATCH 056/142] Split Fever user management from protocol handler --- lib/REST/Fever/API.php | 22 ------- lib/REST/Fever/User.php | 34 +++++++++++ tests/cases/REST/Fever/TestAPI.php | 57 ----------------- tests/cases/REST/Fever/TestUser.php | 94 +++++++++++++++++++++++++++++ tests/phpunit.xml | 1 + 5 files changed, 129 insertions(+), 79 deletions(-) create mode 100644 lib/REST/Fever/User.php create mode 100644 tests/cases/REST/Fever/TestUser.php diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index b80fe8d..c4ad36b 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -97,26 +97,4 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { Arsse::$user->id = $s['user']; return true; } - - public static function userRegister(string $user, string $password = null): string { - $password = $password ?? Arsse::$user->generatePassword(); - $hash = md5("$user:$password"); - $tr = Arsse::$db->begin(); - Arsse::$db->tokenRevoke($user, "fever.login"); - Arsse::$db->tokenCreate($user, "fever.login", $hash); - $tr->commit(); - return $password; - } - - public static function userUnregister(string $user): bool { - return (bool) Arsse::$db->tokenRevoke($user, "fever.login"); - } - - public static function userAuthenticate(string $user, string $password): bool { - try { - return (bool) Arsse::$db->tokenLookup("fever.login", md5("$user:$password")); - } catch (ExceptionInput $e) { - return false; - } - } } diff --git a/lib/REST/Fever/User.php b/lib/REST/Fever/User.php new file mode 100644 index 0000000..ac3cf69 --- /dev/null +++ b/lib/REST/Fever/User.php @@ -0,0 +1,34 @@ +generatePassword(); + $hash = md5("$user:$password"); + $tr = Arsse::$db->begin(); + Arsse::$db->tokenRevoke($user, "fever.login"); + Arsse::$db->tokenCreate($user, "fever.login", $hash); + $tr->commit(); + return $password; + } + + public static function unregister(string $user): bool { + return (bool) Arsse::$db->tokenRevoke($user, "fever.login"); + } + + public static function authenticate(string $user, string $password): bool { + try { + return (bool) Arsse::$db->tokenLookup("fever.login", md5("$user:$password")); + } catch (ExceptionInput $e) { + return false; + } + } +} diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index b1c6901..c76d567 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -144,61 +144,4 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { [true, false, "validUser", ['api_key' => "invalidToken"], ['api' => null], $success], ]; } - - /** @dataProvider providePasswordCreations */ - public function testRegisterAUserPassword(string $user, string $password = null, $exp) { - \Phake::when(Arsse::$user)->generatePassword->thenReturn("RANDOM_PASSWORD"); - \Phake::when(Arsse::$db)->tokenCreate->thenReturnCallback(function($user, $class, $id = null) { - return $id ?? "RANDOM_TOKEN"; - }); - \Phake::when(Arsse::$db)->tokenCreate("john.doe@example.org", $this->anything(), $this->anything())->thenThrow(new UserException("doesNotExist")); - try { - if ($exp instanceof \JKingWeb\Arsse\AbstractException) { - $this->assertException($exp); - API::userRegister($user, $password); - } else { - $this->assertSame($exp, API::userRegister($user, $password)); - } - } finally { - \Phake::verify(Arsse::$db)->tokenRevoke($user, "fever.login"); - \Phake::verify(Arsse::$db)->tokenCreate($user, "fever.login", md5($user.":".($password ?? "RANDOM_PASSWORD"))); - } - } - - public function providePasswordCreations() { - return [ - ["jane.doe@example.com", "secret", "secret"], - ["jane.doe@example.com", "superman", "superman"], - ["jane.doe@example.com", null, "RANDOM_PASSWORD"], - ["john.doe@example.org", null, new UserException("doesNotExist")], - ["john.doe@example.net", null, "RANDOM_PASSWORD"], - ["john.doe@example.net", "secret", "secret"], - ]; - } - - public function testUnregisterAUser() { - \Phake::when(Arsse::$db)->tokenRevoke->thenReturn(3); - $this->assertTrue(API::userUnregister("jane.doe@example.com")); - \Phake::verify(Arsse::$db)->tokenRevoke("jane.doe@example.com", "fever.login"); - \Phake::when(Arsse::$db)->tokenRevoke->thenReturn(0); - $this->assertFalse(API::userUnregister("john.doe@example.com")); - \Phake::verify(Arsse::$db)->tokenRevoke("john.doe@example.com", "fever.login"); - } - - /** @dataProvider provideUserAuthenticationRequests */ - public function testAuthenticateAUserName(string $user, string $password, bool $exp) { - \Phake::when(Arsse::$db)->tokenLookup->thenThrow(new ExceptionInput("constraintViolation")); - \Phake::when(Arsse::$db)->tokenLookup("fever.login", md5("jane.doe@example.com:secret"))->thenReturn(['user' => "jane.doe@example.com"]); - \Phake::when(Arsse::$db)->tokenLookup("fever.login", md5("john.doe@example.com:superman"))->thenReturn(['user' => "john.doe@example.com"]); - $this->assertSame($exp, API::userAuthenticate($user, $password)); - } - - public function provideUserAuthenticationRequests() { - return [ - ["jane.doe@example.com", "secret", true], - ["jane.doe@example.com", "superman", false], - ["john.doe@example.com", "secret", false], - ["john.doe@example.com", "superman", true], - ]; - } } diff --git a/tests/cases/REST/Fever/TestUser.php b/tests/cases/REST/Fever/TestUser.php new file mode 100644 index 0000000..c185647 --- /dev/null +++ b/tests/cases/REST/Fever/TestUser.php @@ -0,0 +1,94 @@ + */ +class TestUser extends \JKingWeb\Arsse\Test\AbstractTest { + protected $u; + + public function setUp() { + self::clearData(); + self::setConf(); + // create a mock user manager + Arsse::$user = \Phake::mock(User::class); + \Phake::when(Arsse::$user)->auth->thenReturn(true); + // create a mock database interface + Arsse::$db = \Phake::mock(Database::class); + \Phake::when(Arsse::$db)->begin->thenReturn(\Phake::mock(Transaction::class)); + // instantiate the handler + $this->u = new FeverUser(); + } + + public function tearDown() { + self::clearData(); + } + + /** @dataProvider providePasswordCreations */ + public function testRegisterAUserPassword(string $user, string $password = null, $exp) { + \Phake::when(Arsse::$user)->generatePassword->thenReturn("RANDOM_PASSWORD"); + \Phake::when(Arsse::$db)->tokenCreate->thenReturnCallback(function($user, $class, $id = null) { + return $id ?? "RANDOM_TOKEN"; + }); + \Phake::when(Arsse::$db)->tokenCreate("john.doe@example.org", $this->anything(), $this->anything())->thenThrow(new UserException("doesNotExist")); + try { + if ($exp instanceof \JKingWeb\Arsse\AbstractException) { + $this->assertException($exp); + $this->u->register($user, $password); + } else { + $this->assertSame($exp, $this->u->register($user, $password)); + } + } finally { + \Phake::verify(Arsse::$db)->tokenRevoke($user, "fever.login"); + \Phake::verify(Arsse::$db)->tokenCreate($user, "fever.login", md5($user.":".($password ?? "RANDOM_PASSWORD"))); + } + } + + public function providePasswordCreations() { + return [ + ["jane.doe@example.com", "secret", "secret"], + ["jane.doe@example.com", "superman", "superman"], + ["jane.doe@example.com", null, "RANDOM_PASSWORD"], + ["john.doe@example.org", null, new UserException("doesNotExist")], + ["john.doe@example.net", null, "RANDOM_PASSWORD"], + ["john.doe@example.net", "secret", "secret"], + ]; + } + + public function testUnregisterAUser() { + \Phake::when(Arsse::$db)->tokenRevoke->thenReturn(3); + $this->assertTrue($this->u->unregister("jane.doe@example.com")); + \Phake::verify(Arsse::$db)->tokenRevoke("jane.doe@example.com", "fever.login"); + \Phake::when(Arsse::$db)->tokenRevoke->thenReturn(0); + $this->assertFalse($this->u->unregister("john.doe@example.com")); + \Phake::verify(Arsse::$db)->tokenRevoke("john.doe@example.com", "fever.login"); + } + + /** @dataProvider provideUserAuthenticationRequests */ + public function testAuthenticateAUserName(string $user, string $password, bool $exp) { + \Phake::when(Arsse::$db)->tokenLookup->thenThrow(new ExceptionInput("constraintViolation")); + \Phake::when(Arsse::$db)->tokenLookup("fever.login", md5("jane.doe@example.com:secret"))->thenReturn(['user' => "jane.doe@example.com"]); + \Phake::when(Arsse::$db)->tokenLookup("fever.login", md5("john.doe@example.com:superman"))->thenReturn(['user' => "john.doe@example.com"]); + $this->assertSame($exp, $this->u->authenticate($user, $password)); + } + + public function provideUserAuthenticationRequests() { + return [ + ["jane.doe@example.com", "secret", true], + ["jane.doe@example.com", "superman", false], + ["john.doe@example.com", "secret", false], + ["john.doe@example.com", "superman", true], + ]; + } +} diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 9200dd8..7c698ab 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -105,6 +105,7 @@ cases/REST/TinyTinyRSS/PDO/TestAPI.php + cases/REST/Fever/TestUser.php cases/REST/Fever/TestAPI.php cases/REST/Fever/PDO/TestAPI.php From f4d4feb69c9d1ffdf51cba9249874c77b49367d4 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Mar 2019 09:53:06 -0400 Subject: [PATCH 057/142] Suppress TLS error from mock server --- RoboFile.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/RoboFile.php b/RoboFile.php index 8df789b..a938b16 100644 --- a/RoboFile.php +++ b/RoboFile.php @@ -98,6 +98,11 @@ class RoboFile extends \Robo\Tasks { return defined("PHP_WINDOWS_VERSION_MAJOR"); } + protected function blackhole(bool $all = false): string { + $hole = $this->isWindows() ? "nul" : "/dev/null"; + return $all ? ">$hole 2>&1" : "2>$hole"; + } + protected function runTests(string $executor, string $set, array $args) : Result { switch ($set) { case "typical": @@ -117,7 +122,7 @@ class RoboFile extends \Robo\Tasks { } $execpath = realpath(self::BASE."vendor-bin/phpunit/vendor/phpunit/phpunit/phpunit"); $confpath = realpath(self::BASE_TEST."phpunit.xml"); - $this->taskServer(8000)->host("localhost")->dir(self::BASE_TEST."docroot")->rawArg("-n")->arg(self::BASE_TEST."server.php")->background()->run(); + $this->taskServer(8000)->host("localhost")->dir(self::BASE_TEST."docroot")->rawArg("-n")->arg(self::BASE_TEST."server.php")->rawArg($this->blackhole())->background()->run(); return $this->taskExec($executor)->arg($execpath)->option("-c", $confpath)->args(array_merge($set, $args))->run(); } From 22c2629078fc712f72a0b4cac6bde75144a2cbe2 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Mar 2019 10:45:05 -0400 Subject: [PATCH 058/142] Partial tests for new CLI features --- lib/CLI.php | 38 ++++++++++++++++++++++++++++++------- lib/REST/Fever/User.php | 6 +++--- tests/cases/CLI/TestCLI.php | 24 +++++++++++++++++++---- 3 files changed, 54 insertions(+), 14 deletions(-) diff --git a/lib/CLI.php b/lib/CLI.php index efb1f99..85ee804 100644 --- a/lib/CLI.php +++ b/lib/CLI.php @@ -6,7 +6,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse; -use Docopt\Response as Opts; +use JKingWeb\Arsse\REST\Fever\User as Fever; class CLI { const USAGE = << [] arsse.php user remove - arsse.php user set-pass [--oldpass=] [] - arsse.php user auth + arsse.php user set-pass [] + [--oldpass=] [--fever] + arsse.php user unset-pass + [--oldpass=] [--fever] + arsse.php user auth [--fever] arsse.php --version arsse.php --help | -h @@ -106,16 +109,36 @@ USAGE_TEXT; return new Conf; } + /** @codeCoverageIgnore */ + protected function getFever(): Fever { + return new Fever; + } + protected function userManage($args): int { switch ($this->command(["add", "remove", "set-pass", "list", "auth"], $args)) { case "add": return $this->userAddOrSetPassword("add", $args[""], $args[""]); case "set-pass": - return $this->userAddOrSetPassword("passwordSet", $args[""], $args[""], $args["--oldpass"]); + if ($args['--fever']) { + $passwd = $this->getFever()->register($args[""], $args[""]); + if (is_null($args[""])) { + echo $passwd.\PHP_EOL; + } + return 0; + } else { + return $this->userAddOrSetPassword("passwordSet", $args[""], $args[""], $args["--oldpass"]); + } + case "unset-pass": + if ($args['--fever']) { + $this->getFever()->unegister($args[""]); + } else { + Arsse::$user->passwordUnset($args[""], $args["--oldpass"]); + } + return 0; case "remove": return (int) !Arsse::$user->remove($args[""]); case "auth": - return $this->userAuthenticate($args[""], $args[""]); + return $this->userAuthenticate($args[""], $args[""], $args["--fever"]); case "list": case "": return $this->userList(); @@ -138,8 +161,9 @@ USAGE_TEXT; return 0; } - protected function userAuthenticate(string $user, string $password): int { - if (Arsse::$user->auth($user, $password)) { + protected function userAuthenticate(string $user, string $password, bool $fever = false): int { + $result = $fever ? $this->getFever()->authenticate($user, $password) : Arsse::$user->auth($user, $password); + if ($result) { echo Arsse::$lang->msg("CLI.Auth.Success").\PHP_EOL; return 0; } else { diff --git a/lib/REST/Fever/User.php b/lib/REST/Fever/User.php index ac3cf69..b702ae4 100644 --- a/lib/REST/Fever/User.php +++ b/lib/REST/Fever/User.php @@ -10,7 +10,7 @@ use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Db\ExceptionInput; class User { - public static function register(string $user, string $password = null): string { + public function register(string $user, string $password = null): string { $password = $password ?? Arsse::$user->generatePassword(); $hash = md5("$user:$password"); $tr = Arsse::$db->begin(); @@ -20,11 +20,11 @@ class User { return $password; } - public static function unregister(string $user): bool { + public function unregister(string $user): bool { return (bool) Arsse::$db->tokenRevoke($user, "fever.login"); } - public static function authenticate(string $user, string $password): bool { + public function authenticate(string $user, string $password): bool { try { return (bool) Arsse::$db->tokenLookup("fever.login", md5("$user:$password")); } catch (ExceptionInput $e) { diff --git a/tests/cases/CLI/TestCLI.php b/tests/cases/CLI/TestCLI.php index 608eebc..2ca1ebe 100644 --- a/tests/cases/CLI/TestCLI.php +++ b/tests/cases/CLI/TestCLI.php @@ -12,6 +12,7 @@ use JKingWeb\Arsse\User; use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Service; use JKingWeb\Arsse\CLI; +use JKingWeb\Arsse\REST\Fever\User as FeverUser; use Phake; /** @covers \JKingWeb\Arsse\CLI */ @@ -174,16 +175,27 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { ($user === "jane.doe@example.com" && $pass === "superman") ); })); + $fever = \Phake::mock(FeverUser::class); + \Phake::when($fever)->authenticate->thenReturn(false); + \Phake::when($fever)->authenticate("john.doe@example.com", "ashalla")->thenReturn(true); + \Phake::when($fever)->authenticate("jane.doe@example.com", "thx1388")->thenReturn(true); + \Phake::when($this->cli)->getFever->thenReturn($fever); $this->assertConsole($this->cli, $cmd, $exitStatus, $output); } public function provideUserAuthentication() { $l = new \JKingWeb\Arsse\Lang; + $success = $l("CLI.Auth.Success"); + $failure = $l("CLI.Auth.Failure"); return [ - ["arsse.php user auth john.doe@example.com secret", 0, $l("CLI.Auth.Success")], - ["arsse.php user auth john.doe@example.com superman", 1, $l("CLI.Auth.Failure")], - ["arsse.php user auth jane.doe@example.com secret", 1, $l("CLI.Auth.Failure")], - ["arsse.php user auth jane.doe@example.com superman", 0, $l("CLI.Auth.Success")], + ["arsse.php user auth john.doe@example.com secret", 0, $success], + ["arsse.php user auth john.doe@example.com superman", 1, $failure], + ["arsse.php user auth jane.doe@example.com secret", 1, $failure], + ["arsse.php user auth jane.doe@example.com superman", 0, $success], + ["arsse.php user auth john.doe@example.com ashalla --fever", 0, $success], + ["arsse.php user auth john.doe@example.com thx1138 --fever", 1, $failure], + ["arsse.php user auth --fever jane.doe@example.com ashalla", 1, $failure], + ["arsse.php user auth --fever jane.doe@example.com thx1138", 0, $success], ]; } @@ -229,4 +241,8 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { ["arsse.php user set-pass jane.doe@example.com", 10402, ""], ]; } + + public function testChangeAFeverPassword() { + $this->markTestIncomplete(); + } } From b8640d73f94cce3b4a6c76cc322f2ac54dff66fc Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Mar 2019 10:47:06 -0400 Subject: [PATCH 059/142] Update PHPUnit --- vendor-bin/phpunit/composer.json | 2 +- vendor-bin/phpunit/composer.lock | 89 +++++++++++++++++--------------- 2 files changed, 48 insertions(+), 43 deletions(-) diff --git a/vendor-bin/phpunit/composer.json b/vendor-bin/phpunit/composer.json index e0854a6..d6c1f86 100644 --- a/vendor-bin/phpunit/composer.json +++ b/vendor-bin/phpunit/composer.json @@ -1,6 +1,6 @@ { "require": { - "phpunit/phpunit": "*", + "phpunit/phpunit": "7.*", "phake/phake": "^3.0", "clue/arguments": "^2.0", "mikey179/vfsStream": "^1.6", diff --git a/vendor-bin/phpunit/composer.lock b/vendor-bin/phpunit/composer.lock index 88d83a5..6e00b3c 100644 --- a/vendor-bin/phpunit/composer.lock +++ b/vendor-bin/phpunit/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5c03bb6fb595eebc1bb3e5fe9ea7c4a0", + "content-hash": "e69de7425d904e9dadfed81536ecd712", "packages": [ { "name": "clue/arguments", @@ -58,27 +58,29 @@ }, { "name": "doctrine/instantiator", - "version": "1.1.0", + "version": "1.2.0", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "185b8868aa9bf7159f5f953ed5afb2d7fcdc3bda" + "reference": "a2c590166b2133a4633738648b6b064edae0814a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/185b8868aa9bf7159f5f953ed5afb2d7fcdc3bda", - "reference": "185b8868aa9bf7159f5f953ed5afb2d7fcdc3bda", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/a2c590166b2133a4633738648b6b064edae0814a", + "reference": "a2c590166b2133a4633738648b6b064edae0814a", "shasum": "" }, "require": { "php": "^7.1" }, "require-dev": { - "athletic/athletic": "~0.1.8", + "doctrine/coding-standard": "^6.0", "ext-pdo": "*", "ext-phar": "*", - "phpunit/phpunit": "^6.2.3", - "squizlabs/php_codesniffer": "^3.0.2" + "phpbench/phpbench": "^0.13", + "phpstan/phpstan-phpunit": "^0.11", + "phpstan/phpstan-shim": "^0.11", + "phpunit/phpunit": "^7.0" }, "type": "library", "extra": { @@ -103,12 +105,12 @@ } ], "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", - "homepage": "https://github.com/doctrine/instantiator", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", "keywords": [ "constructor", "instantiate" ], - "time": "2017-07-22T11:58:36+00:00" + "time": "2019-03-17T17:37:11+00:00" }, { "name": "mikey179/vfsStream", @@ -735,16 +737,16 @@ }, { "name": "phpunit/php-timer", - "version": "2.0.0", + "version": "2.1.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "8b8454ea6958c3dee38453d3bd571e023108c91f" + "reference": "8b389aebe1b8b0578430bda0c7c95a829608e059" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/8b8454ea6958c3dee38453d3bd571e023108c91f", - "reference": "8b8454ea6958c3dee38453d3bd571e023108c91f", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/8b389aebe1b8b0578430bda0c7c95a829608e059", + "reference": "8b389aebe1b8b0578430bda0c7c95a829608e059", "shasum": "" }, "require": { @@ -756,7 +758,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-master": "2.1-dev" } }, "autoload": { @@ -780,7 +782,7 @@ "keywords": [ "timer" ], - "time": "2018-02-01T13:07:23+00:00" + "time": "2019-02-20T10:12:59+00:00" }, { "name": "phpunit/php-token-stream", @@ -833,16 +835,16 @@ }, { "name": "phpunit/phpunit", - "version": "7.5.2", + "version": "7.5.7", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "7c89093bd00f7d5ddf0ab81dee04f801416b4944" + "reference": "eb343b86753d26de07ecba7868fa983104361948" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/7c89093bd00f7d5ddf0ab81dee04f801416b4944", - "reference": "7c89093bd00f7d5ddf0ab81dee04f801416b4944", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/eb343b86753d26de07ecba7868fa983104361948", + "reference": "eb343b86753d26de07ecba7868fa983104361948", "shasum": "" }, "require": { @@ -860,7 +862,7 @@ "phpunit/php-code-coverage": "^6.0.7", "phpunit/php-file-iterator": "^2.0.1", "phpunit/php-text-template": "^1.2.1", - "phpunit/php-timer": "^2.0", + "phpunit/php-timer": "^2.1", "sebastian/comparator": "^3.0", "sebastian/diff": "^3.0", "sebastian/environment": "^4.0", @@ -913,7 +915,7 @@ "testing", "xunit" ], - "time": "2019-01-15T08:19:08+00:00" + "time": "2019-03-16T07:31:17+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", @@ -1026,23 +1028,23 @@ }, { "name": "sebastian/diff", - "version": "3.0.1", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "366541b989927187c4ca70490a35615d3fef2dce" + "reference": "720fcc7e9b5cf384ea68d9d930d480907a0c1a29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/366541b989927187c4ca70490a35615d3fef2dce", - "reference": "366541b989927187c4ca70490a35615d3fef2dce", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/720fcc7e9b5cf384ea68d9d930d480907a0c1a29", + "reference": "720fcc7e9b5cf384ea68d9d930d480907a0c1a29", "shasum": "" }, "require": { "php": "^7.1" }, "require-dev": { - "phpunit/phpunit": "^7.0", + "phpunit/phpunit": "^7.5 || ^8.0", "symfony/process": "^2 || ^3.3 || ^4" }, "type": "library", @@ -1078,32 +1080,35 @@ "unidiff", "unified diff" ], - "time": "2018-06-10T07:54:39+00:00" + "time": "2019-02-04T06:01:07+00:00" }, { "name": "sebastian/environment", - "version": "4.0.1", + "version": "4.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "febd209a219cea7b56ad799b30ebbea34b71eb8f" + "reference": "6fda8ce1974b62b14935adc02a9ed38252eca656" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/febd209a219cea7b56ad799b30ebbea34b71eb8f", - "reference": "febd209a219cea7b56ad799b30ebbea34b71eb8f", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6fda8ce1974b62b14935adc02a9ed38252eca656", + "reference": "6fda8ce1974b62b14935adc02a9ed38252eca656", "shasum": "" }, "require": { "php": "^7.1" }, "require-dev": { - "phpunit/phpunit": "^7.4" + "phpunit/phpunit": "^7.5" + }, + "suggest": { + "ext-posix": "*" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-master": "4.1-dev" } }, "autoload": { @@ -1128,7 +1133,7 @@ "environment", "hhvm" ], - "time": "2018-11-25T09:31:21+00:00" + "time": "2019-02-01T05:27:49+00:00" }, { "name": "sebastian/exporter", @@ -1480,16 +1485,16 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.10.0", + "version": "v1.11.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "e3d826245268269cd66f8326bd8bc066687b4a19" + "reference": "82ebae02209c21113908c229e9883c419720738a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/e3d826245268269cd66f8326bd8bc066687b4a19", - "reference": "e3d826245268269cd66f8326bd8bc066687b4a19", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/82ebae02209c21113908c229e9883c419720738a", + "reference": "82ebae02209c21113908c229e9883c419720738a", "shasum": "" }, "require": { @@ -1501,7 +1506,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.9-dev" + "dev-master": "1.11-dev" } }, "autoload": { @@ -1523,7 +1528,7 @@ }, { "name": "Gert de Pagter", - "email": "backendtea@gmail.com" + "email": "BackEndTea@gmail.com" } ], "description": "Symfony polyfill for ctype functions", @@ -1534,7 +1539,7 @@ "polyfill", "portable" ], - "time": "2018-08-06T14:22:27+00:00" + "time": "2019-02-06T07:57:58+00:00" }, { "name": "theseer/tokenizer", From 802045782005504460a785ca93ca43f3f015c2e4 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Mar 2019 11:28:15 -0400 Subject: [PATCH 060/142] Update dependencies --- vendor-bin/csfixer/composer.lock | 163 ++++++++++---------- vendor-bin/phpunit/composer.lock | 2 +- vendor-bin/robo/composer.lock | 249 ++++++++++++++++++++----------- 3 files changed, 242 insertions(+), 172 deletions(-) diff --git a/vendor-bin/csfixer/composer.lock b/vendor-bin/csfixer/composer.lock index f522cb4..155169e 100644 --- a/vendor-bin/csfixer/composer.lock +++ b/vendor-bin/csfixer/composer.lock @@ -8,16 +8,16 @@ "packages": [ { "name": "composer/semver", - "version": "1.4.2", + "version": "1.5.0", "source": { "type": "git", "url": "https://github.com/composer/semver.git", - "reference": "c7cb9a2095a074d131b65a8a0cd294479d785573" + "reference": "46d9139568ccb8d9e7cdd4539cab7347568a5e2e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/c7cb9a2095a074d131b65a8a0cd294479d785573", - "reference": "c7cb9a2095a074d131b65a8a0cd294479d785573", + "url": "https://api.github.com/repos/composer/semver/zipball/46d9139568ccb8d9e7cdd4539cab7347568a5e2e", + "reference": "46d9139568ccb8d9e7cdd4539cab7347568a5e2e", "shasum": "" }, "require": { @@ -66,20 +66,20 @@ "validation", "versioning" ], - "time": "2016-08-30T16:08:34+00:00" + "time": "2019-03-19T17:25:45+00:00" }, { "name": "composer/xdebug-handler", - "version": "1.3.1", + "version": "1.3.2", "source": { "type": "git", "url": "https://github.com/composer/xdebug-handler.git", - "reference": "dc523135366eb68f22268d069ea7749486458562" + "reference": "d17708133b6c276d6e42ef887a877866b909d892" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/dc523135366eb68f22268d069ea7749486458562", - "reference": "dc523135366eb68f22268d069ea7749486458562", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/d17708133b6c276d6e42ef887a877866b909d892", + "reference": "d17708133b6c276d6e42ef887a877866b909d892", "shasum": "" }, "require": { @@ -110,7 +110,7 @@ "Xdebug", "performance" ], - "time": "2018-11-29T10:59:02+00:00" + "time": "2019-01-28T20:25:53+00:00" }, { "name": "doctrine/annotations", @@ -236,16 +236,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v2.14.0", + "version": "v2.14.2", "source": { "type": "git", "url": "https://github.com/FriendsOfPHP/PHP-CS-Fixer.git", - "reference": "b788ea0af899cedc8114dca7db119c93b6685da2" + "reference": "ff401e58261ffc5934a58f795b3f95b355e276cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/b788ea0af899cedc8114dca7db119c93b6685da2", - "reference": "b788ea0af899cedc8114dca7db119c93b6685da2", + "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/ff401e58261ffc5934a58f795b3f95b355e276cb", + "reference": "ff401e58261ffc5934a58f795b3f95b355e276cb", "shasum": "" }, "require": { @@ -266,9 +266,6 @@ "symfony/process": "^3.0 || ^4.0", "symfony/stopwatch": "^3.0 || ^4.0" }, - "conflict": { - "hhvm": "*" - }, "require-dev": { "johnkary/phpunit-speedtrap": "^1.1 || ^2.0 || ^3.0", "justinrainbow/json-schema": "^5.0", @@ -292,11 +289,6 @@ "php-cs-fixer" ], "type": "application", - "extra": { - "branch-alias": { - "dev-master": "2.14-dev" - } - }, "autoload": { "psr-4": { "PhpCsFixer\\": "src/" @@ -328,7 +320,7 @@ } ], "description": "A tool to automatically fix PHP code style", - "time": "2019-01-04T18:29:47+00:00" + "time": "2019-02-17T17:44:13+00:00" }, { "name": "paragonie/random_compat", @@ -475,16 +467,16 @@ }, { "name": "symfony/console", - "version": "v4.2.2", + "version": "v4.2.4", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "b0a03c1bb0fcbe288629956cf2f1dd3f1dc97522" + "reference": "9dc2299a016497f9ee620be94524e6c0af0280a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/b0a03c1bb0fcbe288629956cf2f1dd3f1dc97522", - "reference": "b0a03c1bb0fcbe288629956cf2f1dd3f1dc97522", + "url": "https://api.github.com/repos/symfony/console/zipball/9dc2299a016497f9ee620be94524e6c0af0280a9", + "reference": "9dc2299a016497f9ee620be94524e6c0af0280a9", "shasum": "" }, "require": { @@ -496,6 +488,9 @@ "symfony/dependency-injection": "<3.4", "symfony/process": "<3.3" }, + "provide": { + "psr/log-implementation": "1.0" + }, "require-dev": { "psr/log": "~1.0", "symfony/config": "~3.4|~4.0", @@ -505,7 +500,7 @@ "symfony/process": "~3.4|~4.0" }, "suggest": { - "psr/log-implementation": "For using the console logger", + "psr/log": "For using the console logger", "symfony/event-dispatcher": "", "symfony/lock": "", "symfony/process": "" @@ -540,7 +535,7 @@ ], "description": "Symfony Console Component", "homepage": "https://symfony.com", - "time": "2019-01-04T15:13:53+00:00" + "time": "2019-02-23T15:17:42+00:00" }, { "name": "symfony/contracts", @@ -612,16 +607,16 @@ }, { "name": "symfony/event-dispatcher", - "version": "v4.2.2", + "version": "v4.2.4", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "887de6d34c86cf0cb6cbf910afb170cdb743cb5e" + "reference": "3354d2e6af986dd71f68b4e5cf4a933ab58697fb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/887de6d34c86cf0cb6cbf910afb170cdb743cb5e", - "reference": "887de6d34c86cf0cb6cbf910afb170cdb743cb5e", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/3354d2e6af986dd71f68b4e5cf4a933ab58697fb", + "reference": "3354d2e6af986dd71f68b4e5cf4a933ab58697fb", "shasum": "" }, "require": { @@ -672,20 +667,20 @@ ], "description": "Symfony EventDispatcher Component", "homepage": "https://symfony.com", - "time": "2019-01-05T16:37:49+00:00" + "time": "2019-02-23T15:17:42+00:00" }, { "name": "symfony/filesystem", - "version": "v4.2.2", + "version": "v4.2.4", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "c2ffd9a93f2d6c5be2f68a0aa7953cc229f871f8" + "reference": "e16b9e471703b2c60b95f14d31c1239f68f11601" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/c2ffd9a93f2d6c5be2f68a0aa7953cc229f871f8", - "reference": "c2ffd9a93f2d6c5be2f68a0aa7953cc229f871f8", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/e16b9e471703b2c60b95f14d31c1239f68f11601", + "reference": "e16b9e471703b2c60b95f14d31c1239f68f11601", "shasum": "" }, "require": { @@ -722,20 +717,20 @@ ], "description": "Symfony Filesystem Component", "homepage": "https://symfony.com", - "time": "2019-01-03T09:07:35+00:00" + "time": "2019-02-07T11:40:08+00:00" }, { "name": "symfony/finder", - "version": "v4.2.2", + "version": "v4.2.4", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "9094d69e8c6ee3fe186a0ec5a4f1401e506071ce" + "reference": "267b7002c1b70ea80db0833c3afe05f0fbde580a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/9094d69e8c6ee3fe186a0ec5a4f1401e506071ce", - "reference": "9094d69e8c6ee3fe186a0ec5a4f1401e506071ce", + "url": "https://api.github.com/repos/symfony/finder/zipball/267b7002c1b70ea80db0833c3afe05f0fbde580a", + "reference": "267b7002c1b70ea80db0833c3afe05f0fbde580a", "shasum": "" }, "require": { @@ -771,20 +766,20 @@ ], "description": "Symfony Finder Component", "homepage": "https://symfony.com", - "time": "2019-01-03T09:07:35+00:00" + "time": "2019-02-23T15:42:05+00:00" }, { "name": "symfony/options-resolver", - "version": "v4.2.2", + "version": "v4.2.4", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "fbcb106aeee72f3450298bf73324d2cc00d083d1" + "reference": "3896e5a7d06fd15fa4947694c8dcdd371ff147d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/fbcb106aeee72f3450298bf73324d2cc00d083d1", - "reference": "fbcb106aeee72f3450298bf73324d2cc00d083d1", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/3896e5a7d06fd15fa4947694c8dcdd371ff147d1", + "reference": "3896e5a7d06fd15fa4947694c8dcdd371ff147d1", "shasum": "" }, "require": { @@ -825,20 +820,20 @@ "configuration", "options" ], - "time": "2019-01-03T09:07:35+00:00" + "time": "2019-02-23T15:17:42+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.10.0", + "version": "v1.11.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "e3d826245268269cd66f8326bd8bc066687b4a19" + "reference": "82ebae02209c21113908c229e9883c419720738a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/e3d826245268269cd66f8326bd8bc066687b4a19", - "reference": "e3d826245268269cd66f8326bd8bc066687b4a19", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/82ebae02209c21113908c229e9883c419720738a", + "reference": "82ebae02209c21113908c229e9883c419720738a", "shasum": "" }, "require": { @@ -850,7 +845,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.9-dev" + "dev-master": "1.11-dev" } }, "autoload": { @@ -872,7 +867,7 @@ }, { "name": "Gert de Pagter", - "email": "BackEndTea@gmail.com" + "email": "backendtea@gmail.com" } ], "description": "Symfony polyfill for ctype functions", @@ -883,20 +878,20 @@ "polyfill", "portable" ], - "time": "2018-08-06T14:22:27+00:00" + "time": "2019-02-06T07:57:58+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.10.0", + "version": "v1.11.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "c79c051f5b3a46be09205c73b80b346e4153e494" + "reference": "fe5e94c604826c35a32fa832f35bd036b6799609" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/c79c051f5b3a46be09205c73b80b346e4153e494", - "reference": "c79c051f5b3a46be09205c73b80b346e4153e494", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/fe5e94c604826c35a32fa832f35bd036b6799609", + "reference": "fe5e94c604826c35a32fa832f35bd036b6799609", "shasum": "" }, "require": { @@ -908,7 +903,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.9-dev" + "dev-master": "1.11-dev" } }, "autoload": { @@ -942,20 +937,20 @@ "portable", "shim" ], - "time": "2018-09-21T13:07:52+00:00" + "time": "2019-02-06T07:57:58+00:00" }, { "name": "symfony/polyfill-php70", - "version": "v1.10.0", + "version": "v1.11.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php70.git", - "reference": "6b88000cdd431cd2e940caa2cb569201f3f84224" + "reference": "bc4858fb611bda58719124ca079baff854149c89" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php70/zipball/6b88000cdd431cd2e940caa2cb569201f3f84224", - "reference": "6b88000cdd431cd2e940caa2cb569201f3f84224", + "url": "https://api.github.com/repos/symfony/polyfill-php70/zipball/bc4858fb611bda58719124ca079baff854149c89", + "reference": "bc4858fb611bda58719124ca079baff854149c89", "shasum": "" }, "require": { @@ -965,7 +960,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.9-dev" + "dev-master": "1.11-dev" } }, "autoload": { @@ -1001,20 +996,20 @@ "portable", "shim" ], - "time": "2018-09-21T06:26:08+00:00" + "time": "2019-02-06T07:57:58+00:00" }, { "name": "symfony/polyfill-php72", - "version": "v1.10.0", + "version": "v1.11.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php72.git", - "reference": "9050816e2ca34a8e916c3a0ae8b9c2fccf68b631" + "reference": "ab50dcf166d5f577978419edd37aa2bb8eabce0c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/9050816e2ca34a8e916c3a0ae8b9c2fccf68b631", - "reference": "9050816e2ca34a8e916c3a0ae8b9c2fccf68b631", + "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/ab50dcf166d5f577978419edd37aa2bb8eabce0c", + "reference": "ab50dcf166d5f577978419edd37aa2bb8eabce0c", "shasum": "" }, "require": { @@ -1023,7 +1018,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.9-dev" + "dev-master": "1.11-dev" } }, "autoload": { @@ -1056,20 +1051,20 @@ "portable", "shim" ], - "time": "2018-09-21T13:07:52+00:00" + "time": "2019-02-06T07:57:58+00:00" }, { "name": "symfony/process", - "version": "v4.2.2", + "version": "v4.2.4", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "ea043ab5d8ed13b467a9087d81cb876aee7f689a" + "reference": "6c05edb11fbeff9e2b324b4270ecb17911a8b7ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/ea043ab5d8ed13b467a9087d81cb876aee7f689a", - "reference": "ea043ab5d8ed13b467a9087d81cb876aee7f689a", + "url": "https://api.github.com/repos/symfony/process/zipball/6c05edb11fbeff9e2b324b4270ecb17911a8b7ad", + "reference": "6c05edb11fbeff9e2b324b4270ecb17911a8b7ad", "shasum": "" }, "require": { @@ -1105,20 +1100,20 @@ ], "description": "Symfony Process Component", "homepage": "https://symfony.com", - "time": "2019-01-03T14:48:52+00:00" + "time": "2019-01-24T22:05:03+00:00" }, { "name": "symfony/stopwatch", - "version": "v4.2.2", + "version": "v4.2.4", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "af62b35760fc92c8dbdce659b4eebdfe0e6a0472" + "reference": "b1a5f646d56a3290230dbc8edf2a0d62cda23f67" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/af62b35760fc92c8dbdce659b4eebdfe0e6a0472", - "reference": "af62b35760fc92c8dbdce659b4eebdfe0e6a0472", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/b1a5f646d56a3290230dbc8edf2a0d62cda23f67", + "reference": "b1a5f646d56a3290230dbc8edf2a0d62cda23f67", "shasum": "" }, "require": { @@ -1155,7 +1150,7 @@ ], "description": "Symfony Stopwatch Component", "homepage": "https://symfony.com", - "time": "2019-01-03T09:07:35+00:00" + "time": "2019-01-16T20:31:39+00:00" } ], "packages-dev": [], diff --git a/vendor-bin/phpunit/composer.lock b/vendor-bin/phpunit/composer.lock index 6e00b3c..016ad85 100644 --- a/vendor-bin/phpunit/composer.lock +++ b/vendor-bin/phpunit/composer.lock @@ -1528,7 +1528,7 @@ }, { "name": "Gert de Pagter", - "email": "BackEndTea@gmail.com" + "email": "backendtea@gmail.com" } ], "description": "Symfony polyfill for ctype functions", diff --git a/vendor-bin/robo/composer.lock b/vendor-bin/robo/composer.lock index 123dceb..8458df8 100644 --- a/vendor-bin/robo/composer.lock +++ b/vendor-bin/robo/composer.lock @@ -8,21 +8,21 @@ "packages": [ { "name": "consolidation/annotated-command", - "version": "2.11.0", + "version": "2.12.0", "source": { "type": "git", "url": "https://github.com/consolidation/annotated-command.git", - "reference": "edea407f57104ed518cc3c3b47d5b84403ee267a" + "reference": "512a2e54c98f3af377589de76c43b24652bcb789" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/consolidation/annotated-command/zipball/edea407f57104ed518cc3c3b47d5b84403ee267a", - "reference": "edea407f57104ed518cc3c3b47d5b84403ee267a", + "url": "https://api.github.com/repos/consolidation/annotated-command/zipball/512a2e54c98f3af377589de76c43b24652bcb789", + "reference": "512a2e54c98f3af377589de76c43b24652bcb789", "shasum": "" }, "require": { "consolidation/output-formatters": "^3.4", - "php": ">=5.4.0", + "php": ">=5.4.5", "psr/log": "^1", "symfony/console": "^2.8|^3|^4", "symfony/event-dispatcher": "^2.5|^3|^4", @@ -100,20 +100,20 @@ } ], "description": "Initialize Symfony Console commands from annotated command class methods.", - "time": "2018-12-29T04:43:17+00:00" + "time": "2019-03-08T16:55:03+00:00" }, { "name": "consolidation/config", - "version": "1.1.1", + "version": "1.2.1", "source": { "type": "git", "url": "https://github.com/consolidation/config.git", - "reference": "925231dfff32f05b787e1fddb265e789b939cf4c" + "reference": "cac1279bae7efb5c7fb2ca4c3ba4b8eb741a96c1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/consolidation/config/zipball/925231dfff32f05b787e1fddb265e789b939cf4c", - "reference": "925231dfff32f05b787e1fddb265e789b939cf4c", + "url": "https://api.github.com/repos/consolidation/config/zipball/cac1279bae7efb5c7fb2ca4c3ba4b8eb741a96c1", + "reference": "cac1279bae7efb5c7fb2ca4c3ba4b8eb741a96c1", "shasum": "" }, "require": { @@ -122,9 +122,9 @@ "php": ">=5.4.0" }, "require-dev": { - "g1a/composer-test-scenarios": "^1", + "g1a/composer-test-scenarios": "^3", + "php-coveralls/php-coveralls": "^1", "phpunit/phpunit": "^5", - "satooshi/php-coveralls": "^1.0", "squizlabs/php_codesniffer": "2.*", "symfony/console": "^2.5|^3|^4", "symfony/yaml": "^2.8.11|^3|^4" @@ -134,6 +134,33 @@ }, "type": "library", "extra": { + "scenarios": { + "symfony4": { + "require-dev": { + "symfony/console": "^4.0" + }, + "config": { + "platform": { + "php": "7.1.3" + } + } + }, + "symfony2": { + "require-dev": { + "symfony/console": "^2.8", + "symfony/event-dispatcher": "^2.8", + "phpunit/phpunit": "^4.8.36" + }, + "remove": [ + "php-coveralls/php-coveralls" + ], + "config": { + "platform": { + "php": "5.4.8" + } + } + } + }, "branch-alias": { "dev-master": "1.x-dev" } @@ -154,7 +181,7 @@ } ], "description": "Provide configuration services for a commandline tool.", - "time": "2018-10-24T17:55:35+00:00" + "time": "2019-03-03T19:37:04+00:00" }, { "name": "consolidation/log", @@ -248,16 +275,16 @@ }, { "name": "consolidation/output-formatters", - "version": "3.4.0", + "version": "3.4.1", "source": { "type": "git", "url": "https://github.com/consolidation/output-formatters.git", - "reference": "a942680232094c4a5b21c0b7e54c20cce623ae19" + "reference": "0881112642ad9059071f13f397f571035b527cb9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/consolidation/output-formatters/zipball/a942680232094c4a5b21c0b7e54c20cce623ae19", - "reference": "a942680232094c4a5b21c0b7e54c20cce623ae19", + "url": "https://api.github.com/repos/consolidation/output-formatters/zipball/0881112642ad9059071f13f397f571035b527cb9", + "reference": "0881112642ad9059071f13f397f571035b527cb9", "shasum": "" }, "require": { @@ -267,11 +294,10 @@ "symfony/finder": "^2.5|^3|^4" }, "require-dev": { - "g1a/composer-test-scenarios": "^2", + "g1a/composer-test-scenarios": "^3", + "php-coveralls/php-coveralls": "^1", "phpunit/phpunit": "^5.7.27", - "satooshi/php-coveralls": "^2", "squizlabs/php_codesniffer": "^2.7", - "symfony/console": "3.2.3", "symfony/var-dumper": "^2.8|^3|^4", "victorjonsson/markdowndocs": "^1.3" }, @@ -280,6 +306,52 @@ }, "type": "library", "extra": { + "scenarios": { + "symfony4": { + "require": { + "symfony/console": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^6" + }, + "config": { + "platform": { + "php": "7.1.3" + } + } + }, + "symfony3": { + "require": { + "symfony/console": "^3.4", + "symfony/finder": "^3.4", + "symfony/var-dumper": "^3.4" + }, + "config": { + "platform": { + "php": "5.6.32" + } + } + }, + "symfony2": { + "require": { + "symfony/console": "^2.8" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.36" + }, + "remove": [ + "php-coveralls/php-coveralls" + ], + "config": { + "platform": { + "php": "5.4.8" + } + }, + "scenario-options": { + "create-lockfile": "false" + } + } + }, "branch-alias": { "dev-master": "3.x-dev" } @@ -300,25 +372,25 @@ } ], "description": "Format text by applying transformations provided by plug-in formatters.", - "time": "2018-10-19T22:35:38+00:00" + "time": "2019-03-14T03:45:44+00:00" }, { "name": "consolidation/robo", - "version": "1.4.3", + "version": "1.4.9", "source": { "type": "git", "url": "https://github.com/consolidation/Robo.git", - "reference": "d0b6f516ec940add7abed4f1432d30cca5f8ae0c" + "reference": "5c6b3840a45afda1cbffbb3bb1f94dd5f9f83345" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/consolidation/Robo/zipball/d0b6f516ec940add7abed4f1432d30cca5f8ae0c", - "reference": "d0b6f516ec940add7abed4f1432d30cca5f8ae0c", + "url": "https://api.github.com/repos/consolidation/Robo/zipball/5c6b3840a45afda1cbffbb3bb1f94dd5f9f83345", + "reference": "5c6b3840a45afda1cbffbb3bb1f94dd5f9f83345", "shasum": "" }, "require": { "consolidation/annotated-command": "^2.10.2", - "consolidation/config": "^1.0.10", + "consolidation/config": "^1.2", "consolidation/log": "~1", "consolidation/output-formatters": "^3.1.13", "consolidation/self-update": "^1", @@ -344,7 +416,7 @@ "natxet/cssmin": "3.0.4", "nikic/php-parser": "^3.1.5", "patchwork/jsqueeze": "~2", - "pear/archive_tar": "^1.4.2", + "pear/archive_tar": "^1.4.4", "php-coveralls/php-coveralls": "^1", "phpunit/php-code-coverage": "~2|~4", "squizlabs/php_codesniffer": "^2.8" @@ -408,7 +480,7 @@ } ], "description": "Modern task runner", - "time": "2019-01-02T21:33:28+00:00" + "time": "2019-03-19T18:07:19+00:00" }, { "name": "consolidation/self-update", @@ -712,16 +784,16 @@ }, { "name": "pear/archive_tar", - "version": "1.4.5", + "version": "1.4.6", "source": { "type": "git", "url": "https://github.com/pear/Archive_Tar.git", - "reference": "ff716ca697c5e9e8593212cb785ffd03ee11b01f" + "reference": "b8e33f9063a7cd1d20f079014f8382b3a7aee47e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pear/Archive_Tar/zipball/ff716ca697c5e9e8593212cb785ffd03ee11b01f", - "reference": "ff716ca697c5e9e8593212cb785ffd03ee11b01f", + "url": "https://api.github.com/repos/pear/Archive_Tar/zipball/b8e33f9063a7cd1d20f079014f8382b3a7aee47e", + "reference": "b8e33f9063a7cd1d20f079014f8382b3a7aee47e", "shasum": "" }, "require": { @@ -774,20 +846,20 @@ "archive", "tar" ], - "time": "2019-01-02T21:45:13+00:00" + "time": "2019-02-01T11:10:38+00:00" }, { "name": "pear/console_getopt", - "version": "v1.4.1", + "version": "v1.4.2", "source": { "type": "git", "url": "https://github.com/pear/Console_Getopt.git", - "reference": "82f05cd1aa3edf34e19aa7c8ca312ce13a6a577f" + "reference": "6c77aeb625b32bd752e89ee17972d103588b90c0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pear/Console_Getopt/zipball/82f05cd1aa3edf34e19aa7c8ca312ce13a6a577f", - "reference": "82f05cd1aa3edf34e19aa7c8ca312ce13a6a577f", + "url": "https://api.github.com/repos/pear/Console_Getopt/zipball/6c77aeb625b32bd752e89ee17972d103588b90c0", + "reference": "6c77aeb625b32bd752e89ee17972d103588b90c0", "shasum": "" }, "type": "library", @@ -821,20 +893,20 @@ } ], "description": "More info available on: http://pear.php.net/package/Console_Getopt", - "time": "2015-07-20T20:28:12+00:00" + "time": "2019-02-06T16:52:33+00:00" }, { "name": "pear/pear-core-minimal", - "version": "v1.10.7", + "version": "v1.10.9", "source": { "type": "git", "url": "https://github.com/pear/pear-core-minimal.git", - "reference": "19a3e0fcd50492c4357372f623f55f1b144346da" + "reference": "742be8dd68c746a01e4b0a422258e9c9cae1c37f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pear/pear-core-minimal/zipball/19a3e0fcd50492c4357372f623f55f1b144346da", - "reference": "19a3e0fcd50492c4357372f623f55f1b144346da", + "url": "https://api.github.com/repos/pear/pear-core-minimal/zipball/742be8dd68c746a01e4b0a422258e9c9cae1c37f", + "reference": "742be8dd68c746a01e4b0a422258e9c9cae1c37f", "shasum": "" }, "require": { @@ -865,7 +937,7 @@ } ], "description": "Minimal set of PEAR core files to be used as composer dependency", - "time": "2018-12-05T20:03:52+00:00" + "time": "2019-03-13T18:15:44+00:00" }, { "name": "pear/pear_exception", @@ -1020,16 +1092,16 @@ }, { "name": "symfony/console", - "version": "v4.2.2", + "version": "v4.2.4", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "b0a03c1bb0fcbe288629956cf2f1dd3f1dc97522" + "reference": "9dc2299a016497f9ee620be94524e6c0af0280a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/b0a03c1bb0fcbe288629956cf2f1dd3f1dc97522", - "reference": "b0a03c1bb0fcbe288629956cf2f1dd3f1dc97522", + "url": "https://api.github.com/repos/symfony/console/zipball/9dc2299a016497f9ee620be94524e6c0af0280a9", + "reference": "9dc2299a016497f9ee620be94524e6c0af0280a9", "shasum": "" }, "require": { @@ -1041,6 +1113,9 @@ "symfony/dependency-injection": "<3.4", "symfony/process": "<3.3" }, + "provide": { + "psr/log-implementation": "1.0" + }, "require-dev": { "psr/log": "~1.0", "symfony/config": "~3.4|~4.0", @@ -1050,7 +1125,7 @@ "symfony/process": "~3.4|~4.0" }, "suggest": { - "psr/log-implementation": "For using the console logger", + "psr/log": "For using the console logger", "symfony/event-dispatcher": "", "symfony/lock": "", "symfony/process": "" @@ -1085,7 +1160,7 @@ ], "description": "Symfony Console Component", "homepage": "https://symfony.com", - "time": "2019-01-04T15:13:53+00:00" + "time": "2019-02-23T15:17:42+00:00" }, { "name": "symfony/contracts", @@ -1157,16 +1232,16 @@ }, { "name": "symfony/event-dispatcher", - "version": "v4.2.2", + "version": "v4.2.4", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "887de6d34c86cf0cb6cbf910afb170cdb743cb5e" + "reference": "3354d2e6af986dd71f68b4e5cf4a933ab58697fb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/887de6d34c86cf0cb6cbf910afb170cdb743cb5e", - "reference": "887de6d34c86cf0cb6cbf910afb170cdb743cb5e", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/3354d2e6af986dd71f68b4e5cf4a933ab58697fb", + "reference": "3354d2e6af986dd71f68b4e5cf4a933ab58697fb", "shasum": "" }, "require": { @@ -1217,20 +1292,20 @@ ], "description": "Symfony EventDispatcher Component", "homepage": "https://symfony.com", - "time": "2019-01-05T16:37:49+00:00" + "time": "2019-02-23T15:17:42+00:00" }, { "name": "symfony/filesystem", - "version": "v4.2.2", + "version": "v4.2.4", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "c2ffd9a93f2d6c5be2f68a0aa7953cc229f871f8" + "reference": "e16b9e471703b2c60b95f14d31c1239f68f11601" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/c2ffd9a93f2d6c5be2f68a0aa7953cc229f871f8", - "reference": "c2ffd9a93f2d6c5be2f68a0aa7953cc229f871f8", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/e16b9e471703b2c60b95f14d31c1239f68f11601", + "reference": "e16b9e471703b2c60b95f14d31c1239f68f11601", "shasum": "" }, "require": { @@ -1267,20 +1342,20 @@ ], "description": "Symfony Filesystem Component", "homepage": "https://symfony.com", - "time": "2019-01-03T09:07:35+00:00" + "time": "2019-02-07T11:40:08+00:00" }, { "name": "symfony/finder", - "version": "v4.2.2", + "version": "v4.2.4", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "9094d69e8c6ee3fe186a0ec5a4f1401e506071ce" + "reference": "267b7002c1b70ea80db0833c3afe05f0fbde580a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/9094d69e8c6ee3fe186a0ec5a4f1401e506071ce", - "reference": "9094d69e8c6ee3fe186a0ec5a4f1401e506071ce", + "url": "https://api.github.com/repos/symfony/finder/zipball/267b7002c1b70ea80db0833c3afe05f0fbde580a", + "reference": "267b7002c1b70ea80db0833c3afe05f0fbde580a", "shasum": "" }, "require": { @@ -1316,20 +1391,20 @@ ], "description": "Symfony Finder Component", "homepage": "https://symfony.com", - "time": "2019-01-03T09:07:35+00:00" + "time": "2019-02-23T15:42:05+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.10.0", + "version": "v1.11.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "e3d826245268269cd66f8326bd8bc066687b4a19" + "reference": "82ebae02209c21113908c229e9883c419720738a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/e3d826245268269cd66f8326bd8bc066687b4a19", - "reference": "e3d826245268269cd66f8326bd8bc066687b4a19", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/82ebae02209c21113908c229e9883c419720738a", + "reference": "82ebae02209c21113908c229e9883c419720738a", "shasum": "" }, "require": { @@ -1341,7 +1416,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.9-dev" + "dev-master": "1.11-dev" } }, "autoload": { @@ -1363,7 +1438,7 @@ }, { "name": "Gert de Pagter", - "email": "BackEndTea@gmail.com" + "email": "backendtea@gmail.com" } ], "description": "Symfony polyfill for ctype functions", @@ -1374,20 +1449,20 @@ "polyfill", "portable" ], - "time": "2018-08-06T14:22:27+00:00" + "time": "2019-02-06T07:57:58+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.10.0", + "version": "v1.11.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "c79c051f5b3a46be09205c73b80b346e4153e494" + "reference": "fe5e94c604826c35a32fa832f35bd036b6799609" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/c79c051f5b3a46be09205c73b80b346e4153e494", - "reference": "c79c051f5b3a46be09205c73b80b346e4153e494", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/fe5e94c604826c35a32fa832f35bd036b6799609", + "reference": "fe5e94c604826c35a32fa832f35bd036b6799609", "shasum": "" }, "require": { @@ -1399,7 +1474,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.9-dev" + "dev-master": "1.11-dev" } }, "autoload": { @@ -1433,20 +1508,20 @@ "portable", "shim" ], - "time": "2018-09-21T13:07:52+00:00" + "time": "2019-02-06T07:57:58+00:00" }, { "name": "symfony/process", - "version": "v3.4.21", + "version": "v3.4.23", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "0d41dd7d95ed179aed6a13393b0f4f97bfa2d25c" + "reference": "009f8dda80930e89e8344a4e310b08f9ff07dd2e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/0d41dd7d95ed179aed6a13393b0f4f97bfa2d25c", - "reference": "0d41dd7d95ed179aed6a13393b0f4f97bfa2d25c", + "url": "https://api.github.com/repos/symfony/process/zipball/009f8dda80930e89e8344a4e310b08f9ff07dd2e", + "reference": "009f8dda80930e89e8344a4e310b08f9ff07dd2e", "shasum": "" }, "require": { @@ -1482,20 +1557,20 @@ ], "description": "Symfony Process Component", "homepage": "https://symfony.com", - "time": "2019-01-02T21:24:08+00:00" + "time": "2019-01-16T13:27:11+00:00" }, { "name": "symfony/yaml", - "version": "v4.2.2", + "version": "v4.2.4", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "d0aa6c0ea484087927b49fd513383a7d36190ca6" + "reference": "761fa560a937fd7686e5274ff89dcfa87a5047df" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/d0aa6c0ea484087927b49fd513383a7d36190ca6", - "reference": "d0aa6c0ea484087927b49fd513383a7d36190ca6", + "url": "https://api.github.com/repos/symfony/yaml/zipball/761fa560a937fd7686e5274ff89dcfa87a5047df", + "reference": "761fa560a937fd7686e5274ff89dcfa87a5047df", "shasum": "" }, "require": { @@ -1541,7 +1616,7 @@ ], "description": "Symfony Yaml Component", "homepage": "https://symfony.com", - "time": "2019-01-03T09:07:35+00:00" + "time": "2019-02-23T15:17:42+00:00" } ], "packages-dev": [], From 65f723c7d4ed2f53e3ad764713672d83ba8021bc Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Mar 2019 11:30:35 -0400 Subject: [PATCH 061/142] Fix missing reference to author in TT-RSS. --- lib/REST/TinyTinyRSS/API.php | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index a3572ba..8bf85bc 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -1286,6 +1286,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { "id", "guid", "title", + "author", "url", "unread", "starred", From 1e83350dd0c1c91c34682c8eda17a067933d32b4 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Mar 2019 11:57:31 -0400 Subject: [PATCH 062/142] Version bump --- CHANGELOG | 12 ++++++++++++ lib/Arsse.php | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 0bd9c39..f3baeb2 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,15 @@ +Version 0.7.1 (2019-03-25) +========================== + +Bug fixes: +- Correctly initialize new on-disk SQLite databases +- Retry queries on schema changes with PDO SQLite +- Correctly read author name from database in Tiny Tiny RSS +- Update internal version number to correct version + +Changes: +- Improve performance of lesser-used database queries + Version 0.7.0 (2019-03-02) ========================== diff --git a/lib/Arsse.php b/lib/Arsse.php index 7fbd1b2..6de2425 100644 --- a/lib/Arsse.php +++ b/lib/Arsse.php @@ -7,7 +7,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse; class Arsse { - const VERSION = "0.6.1"; + const VERSION = "0.7.1"; /** @var Lang */ public static $lang; From a7fe8791746e8d6453e967413b9746fa7d08ac40 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Mar 2019 14:24:58 -0400 Subject: [PATCH 063/142] Fix CLI auth test --- tests/cases/CLI/TestCLI.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cases/CLI/TestCLI.php b/tests/cases/CLI/TestCLI.php index 2ca1ebe..f7f2a7c 100644 --- a/tests/cases/CLI/TestCLI.php +++ b/tests/cases/CLI/TestCLI.php @@ -178,7 +178,7 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { $fever = \Phake::mock(FeverUser::class); \Phake::when($fever)->authenticate->thenReturn(false); \Phake::when($fever)->authenticate("john.doe@example.com", "ashalla")->thenReturn(true); - \Phake::when($fever)->authenticate("jane.doe@example.com", "thx1388")->thenReturn(true); + \Phake::when($fever)->authenticate("jane.doe@example.com", "thx1138")->thenReturn(true); \Phake::when($this->cli)->getFever->thenReturn($fever); $this->assertConsole($this->cli, $cmd, $exitStatus, $output); } From 54be5997d153d2cedc453840bf340c017b9f99c4 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Mar 2019 15:03:41 -0400 Subject: [PATCH 064/142] CLI tests for password changing and clearing --- tests/cases/CLI/TestCLI.php | 49 ++++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/tests/cases/CLI/TestCLI.php b/tests/cases/CLI/TestCLI.php index f7f2a7c..9a2d622 100644 --- a/tests/cases/CLI/TestCLI.php +++ b/tests/cases/CLI/TestCLI.php @@ -221,28 +221,59 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { /** @dataProvider provideUserPasswordChanges */ public function testChangeAUserPassword(string $cmd, int $exitStatus, string $output) { - // FIXME: Phake is somehow unable to mock the User class correctly, so we use PHPUnit's mocks instead - Arsse::$user = $this->createMock(User::class); - Arsse::$user->method("passwordSet")->will($this->returnCallback(function($user, $pass = null) { + $passwordChange = function($user, $pass = null) { switch ($user) { case "jane.doe@example.com": throw new \JKingWeb\Arsse\User\Exception("doesNotExist"); case "john.doe@example.com": return is_null($pass) ? "random password" : $pass; } - })); + }; + // FIXME: Phake is somehow unable to mock the User class correctly, so we use PHPUnit's mocks instead + Arsse::$user = $this->createMock(User::class); + Arsse::$user->method("passwordSet")->will($this->returnCallback($passwordChange)); + $fever = \Phake::mock(FeverUser::class); + \Phake::when($fever)->register->thenReturnCallback($passwordChange); + \Phake::when($this->cli)->getFever->thenReturn($fever); $this->assertConsole($this->cli, $cmd, $exitStatus, $output); } public function provideUserPasswordChanges() { return [ - ["arsse.php user set-pass john.doe@example.com", 0, "random password"], - ["arsse.php user set-pass john.doe@example.com superman", 0, ""], - ["arsse.php user set-pass jane.doe@example.com", 10402, ""], + ["arsse.php user set-pass john.doe@example.com", 0, "random password"], + ["arsse.php user set-pass john.doe@example.com superman", 0, ""], + ["arsse.php user set-pass jane.doe@example.com", 10402, ""], + ["arsse.php user set-pass john.doe@example.com --fever", 0, "random password"], + ["arsse.php user set-pass --fever john.doe@example.com superman", 0, ""], + ["arsse.php user set-pass jane.doe@example.com --fever", 10402, ""], ]; } - public function testChangeAFeverPassword() { - $this->markTestIncomplete(); + /** @dataProvider provideUserPasswordClearings */ + public function testClearAUserPassword(string $cmd, int $exitStatus, string $output) { + $passwordClear = function($user) { + switch ($user) { + case "jane.doe@example.com": + throw new \JKingWeb\Arsse\User\Exception("doesNotExist"); + case "john.doe@example.com": + return true; + } + }; + // FIXME: Phake is somehow unable to mock the User class correctly, so we use PHPUnit's mocks instead + Arsse::$user = $this->createMock(User::class); + Arsse::$user->method("passwordUnet")->will($this->returnCallback($passwordChange)); + $fever = \Phake::mock(FeverUser::class); + \Phake::when($fever)->unregister->thenReturnCallback($passwordChange); + \Phake::when($this->cli)->getFever->thenReturn($fever); + $this->assertConsole($this->cli, $cmd, $exitStatus, $output); + } + + public function provideUserPasswordClearings() { + return [ + ["arsse.php user unset-pass john.doe@example.com", 0, ""], + ["arsse.php user unset-pass jane.doe@example.com", 10402, ""], + ["arsse.php user unset-pass john.doe@example.com --fever", 0, ""], + ["arsse.php user unset-pass jane.doe@example.com --fever", 10402, ""], + ]; } } From 9c61f967e3ba7ab60abffda22543950cefb1a5dd Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Mar 2019 17:07:28 -0400 Subject: [PATCH 065/142] Correct CLI password clearing --- CHANGELOG | 8 ++++++++ lib/CLI.php | 4 ++-- tests/cases/CLI/TestCLI.php | 4 ++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index f3baeb2..edc4b0a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,11 @@ +Version 0.8.0 (2019-??-??) +========================== + +New features: +- Support for the Fever protocol (see README.md for details) +- Command line functionality for clearing a password, disabling the account +- Command line options for dealing with Fever passwords + Version 0.7.1 (2019-03-25) ========================== diff --git a/lib/CLI.php b/lib/CLI.php index 85ee804..9693698 100644 --- a/lib/CLI.php +++ b/lib/CLI.php @@ -115,7 +115,7 @@ USAGE_TEXT; } protected function userManage($args): int { - switch ($this->command(["add", "remove", "set-pass", "list", "auth"], $args)) { + switch ($this->command(["add", "remove", "set-pass", "unset-pass", "list", "auth"], $args)) { case "add": return $this->userAddOrSetPassword("add", $args[""], $args[""]); case "set-pass": @@ -130,7 +130,7 @@ USAGE_TEXT; } case "unset-pass": if ($args['--fever']) { - $this->getFever()->unegister($args[""]); + $this->getFever()->unregister($args[""]); } else { Arsse::$user->passwordUnset($args[""], $args["--oldpass"]); } diff --git a/tests/cases/CLI/TestCLI.php b/tests/cases/CLI/TestCLI.php index 9a2d622..3f1c3d3 100644 --- a/tests/cases/CLI/TestCLI.php +++ b/tests/cases/CLI/TestCLI.php @@ -261,9 +261,9 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { }; // FIXME: Phake is somehow unable to mock the User class correctly, so we use PHPUnit's mocks instead Arsse::$user = $this->createMock(User::class); - Arsse::$user->method("passwordUnet")->will($this->returnCallback($passwordChange)); + Arsse::$user->method("passwordUnset")->will($this->returnCallback($passwordClear)); $fever = \Phake::mock(FeverUser::class); - \Phake::when($fever)->unregister->thenReturnCallback($passwordChange); + \Phake::when($fever)->unregister->thenReturnCallback($passwordClear); \Phake::when($this->cli)->getFever->thenReturn($fever); $this->assertConsole($this->cli, $cmd, $exitStatus, $output); } From acb3973149db12eeb95ad207d2f476ab37a9e148 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 26 Mar 2019 08:53:26 -0400 Subject: [PATCH 066/142] Prototype implementation of Fever groups and feeds --- lib/Database.php | 2 +- lib/REST/Fever/API.php | 65 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index bfbcfee..86bb8b3 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -748,7 +748,7 @@ class Database { $q = new Query( "SELECT arsse_subscriptions.id as id, - arsse_subscriptions.feed, + arsse_subscriptions.feed as feed, url,favicon,source,folder,pinned,err_count,err_msg,order_type,added, arsse_feeds.updated as updated, topmost.top as top_folder, diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index c4ad36b..0d24337 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -12,9 +12,9 @@ use JKingWeb\Arsse\User; use JKingWeb\Arsse\Service; use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Misc\ValueInfo; +use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\AbstractException; use JKingWeb\Arsse\Db\ExceptionInput; -use JKingWeb\Arsse\Feed\Exception as FeedException; use JKingWeb\Arsse\REST\Target; use JKingWeb\Arsse\REST\Exception404; use JKingWeb\Arsse\REST\Exception405; @@ -65,7 +65,19 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { return $this->formatResponse($out, $xml); } // handle each possible parameter - # do stuff + if (array_key_exists("feeds", $inR) || array_key_exists("groups", $inR)) { + $groupData = (array) Arsse::$db->tagSummarize(Arsse::$user->id); + if (array_key_exists("groups", $inR)) { + $out['groups'] = $this->getGroups($groupData); + } + if (array_key_exists("feeds", $inR)) { + $out['feeds'] = $this->getFeeds(); + } + $out['feeds_groups'] = $this->getRelationships($groupData); + } + if (array_key_exists("favicons", $inR)) { + # deal with favicons + } // return the result return $this->formatResponse($out, $xml); break; @@ -97,4 +109,53 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { Arsse::$user->id = $s['user']; return true; } + + protected function getFeeds(): array { + $out = []; + foreach (arsse::$db->subscriptionList(Arsse::$user->id) as $sub) { + $out[] = [ + 'id' => (int) $sub['id'], + 'favicon_id' => (int) ($sub['favicon'] ? $sub['feed'] : 0), + 'title' => (string) $sub['title'], + 'url' => $sub['url'], + 'site_url' => $sub['source'], + 'is_spark' => 0, + 'lat_updated_on_time' => Date::transform($sub['updated'], "unix"), + ]; + } + return $out; + } + + protected function getGroups(array $data): array { + $out = []; + $seen = []; + foreach ($data as $member) { + if (!($seen[$member['id']] ?? false)) { + $seen[$member['id']] = true; + $out[] = [ + 'id' => (int) $member['id'], + 'title' => $member['name'], + ]; + } + } + return $out; + } + + protected function getRelationships(array $data): array { + $out = []; + $sets = []; + foreach ($data as $member) { + if (!isset($sets[$member['id']])) { + $sets[$member['id']] = []; + } + $sets[$member['id']][] = (int) $member['subscription']; + } + foreach ($sets as $id => $subs) { + $out[] = [ + 'group_id' => (int) $id, + 'feed_ids' => implode(",", $subs), + ]; + } + return $out; + } } From d8407330a0c478b4add975555492934747355f8b Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 26 Mar 2019 16:51:44 -0400 Subject: [PATCH 067/142] Add a function to get when feeds were last updated This is an optimization for Fever, which returns this information with every API call. --- lib/Database.php | 20 ++++++++++++++++- tests/cases/Database/SeriesSubscription.php | 24 ++++++++++++++++++--- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 86bb8b3..2486907 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -10,7 +10,6 @@ use JKingWeb\DrUUID\UUID; use JKingWeb\Arsse\Db\Statement; use JKingWeb\Arsse\Misc\Query; use JKingWeb\Arsse\Context\Context; -use JKingWeb\Arsse\Context\ExclusionContext; use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\ValueInfo; @@ -751,6 +750,8 @@ class Database { arsse_subscriptions.feed as feed, url,favicon,source,folder,pinned,err_count,err_msg,order_type,added, arsse_feeds.updated as updated, + arsse_feeds.modified as edited, + arsse_subscriptions.modified as modified, topmost.top as top_folder, coalesce(arsse_subscriptions.title, arsse_feeds.title) as title, (articles - marked) as unread @@ -946,6 +947,23 @@ class Database { return (string) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); } + /** Returns the time at which any of a user's subscriptions (or a specific subscription) was last refreshed, as a DateTimeImmutable object */ + public function subscriptionRefreshed(string $user, int $id = null) { + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + $q = new Query("SELECT max(arsse_feeds.updated) from arsse_feeds join arsse_subscriptions on arsse_subscriptions.feed = arsse_feeds.id"); + $q->setWhere("arsse_subscriptions.owner = ?", "str", $user); + if ($id) { + $q->setWhere("arsse_subscriptions.id = ?", "int", $id); + } + $out = $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); + if (!$out && $id) { + throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "feed", 'id' => $id]); + } + return ValueInfo::normalize($out, ValueInfo::T_DATE | ValueInfo::M_NULL, "sql"); + } + /** Ensures the specified subscription exists and raises an exception otherwise * * Returns an associative array containing the id of the subscription and the id of the underlying newsfeed diff --git a/tests/cases/Database/SeriesSubscription.php b/tests/cases/Database/SeriesSubscription.php index 9756a28..d65fb3e 100644 --- a/tests/cases/Database/SeriesSubscription.php +++ b/tests/cases/Database/SeriesSubscription.php @@ -47,6 +47,7 @@ trait SeriesSubscription { 'title' => "str", 'username' => "str", 'password' => "str", + 'updated' => "datetime", 'next_fetch' => "datetime", 'favicon' => "str", ], @@ -134,9 +135,9 @@ trait SeriesSubscription { ], ]; $this->data['arsse_feeds']['rows'] = [ - [1,"http://example.com/feed1", "Ook", "", "",strtotime("now"),''], - [2,"http://example.com/feed2", "eek", "", "",strtotime("now - 1 hour"),'http://example.com/favicon.ico'], - [3,"http://example.com/feed3", "Ack", "", "",strtotime("now + 1 hour"),''], + [1,"http://example.com/feed1", "Ook", "", "",strtotime("now"),strtotime("now"),''], + [2,"http://example.com/feed2", "eek", "", "",strtotime("now - 1 hour"),strtotime("now - 1 hour"),'http://example.com/favicon.ico'], + [3,"http://example.com/feed3", "Ack", "", "",strtotime("now + 1 hour"),strtotime("now + 1 hour"),''], ]; // initialize a partial mock of the Database object to later manipulate the feedUpdate method Arsse::$db = Phake::partialMock(Database::class, static::$drv); @@ -491,4 +492,21 @@ trait SeriesSubscription { $this->assertException("notAuthorized", "User", "ExceptionAuthz"); Arsse::$db->subscriptionTagsGet("john.doe@example.com", 1); } + + public function testGetRefreshTimeOfASubscription() { + $user = "john.doe@example.com"; + $this->assertTime(strtotime("now + 1 hour"), Arsse::$db->subscriptionRefreshed($user)); + $this->assertTime(strtotime("now - 1 hour"), Arsse::$db->subscriptionRefreshed($user, 1)); + } + + public function testGetRefreshTimeOfAMissingSubscription() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + $this->assertTime(strtotime("now - 1 hour"), Arsse::$db->subscriptionRefreshed("john.doe@example.com", 2)); + } + + public function testGetRefreshTimeOfASubscriptionWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + $this->assertTime(strtotime("now + 1 hour"), Arsse::$db->subscriptionRefreshed("john.doe@example.com")); + } } From 7faec3b0db504a530e0205d6cad30b779931c1af Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 27 Mar 2019 11:54:47 -0400 Subject: [PATCH 068/142] Fever fixes - Ensure the last refresh time is included in authenticated requests - Use a partial mock in auth tests so that other processing does not get in the way of results - Make sure the group list includes unused groups - Make sure the update time of subscriptions is correct --- lib/REST/Fever/API.php | 57 +++++++++++++++--------------- tests/cases/REST/Fever/TestAPI.php | 15 +++++--- 2 files changed, 39 insertions(+), 33 deletions(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 0d24337..c5a93a3 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -60,23 +60,9 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // check that the user specified credentials if ($this->logIn(strtolower($inW['api_key'] ?? ""))) { $out['auth'] = 1; + $out = $this->processRequest($out, $inR, $inW); } else { $out['auth'] = 0; - return $this->formatResponse($out, $xml); - } - // handle each possible parameter - if (array_key_exists("feeds", $inR) || array_key_exists("groups", $inR)) { - $groupData = (array) Arsse::$db->tagSummarize(Arsse::$user->id); - if (array_key_exists("groups", $inR)) { - $out['groups'] = $this->getGroups($groupData); - } - if (array_key_exists("feeds", $inR)) { - $out['feeds'] = $this->getFeeds(); - } - $out['feeds_groups'] = $this->getRelationships($groupData); - } - if (array_key_exists("favicons", $inR)) { - # deal with favicons } // return the result return $this->formatResponse($out, $xml); @@ -86,6 +72,25 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } } + protected function processRequest(array $out, array $G, array $P): array { + // add base metadata + $out['last_refreshed_on_time'] = Date::transform(Arsse::$db->subscriptionRefreshed(Arsse::$user->id), "unix"); + // handle each possible parameter + if (array_key_exists("feeds", $G) || array_key_exists("groups", $G)) { + if (array_key_exists("groups", $G)) { + $out['groups'] = $this->getGroups(); + } + if (array_key_exists("feeds", $G)) { + $out['feeds'] = $this->getFeeds(); + } + $out['feeds_groups'] = $this->getRelationships(); + } + if (array_key_exists("favicons", $G)) { + # deal with favicons + } + return $out; + } + protected function formatResponse(array $data, bool $xml): ResponseInterface { if ($xml) { throw \Exception("Not implemented yet"); @@ -120,31 +125,27 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 'url' => $sub['url'], 'site_url' => $sub['source'], 'is_spark' => 0, - 'lat_updated_on_time' => Date::transform($sub['updated'], "unix"), + 'lat_updated_on_time' => Date::transform($sub['edited'], "unix", "sql"), ]; } return $out; } - protected function getGroups(array $data): array { + protected function getGroups(): array { $out = []; - $seen = []; - foreach ($data as $member) { - if (!($seen[$member['id']] ?? false)) { - $seen[$member['id']] = true; - $out[] = [ - 'id' => (int) $member['id'], - 'title' => $member['name'], - ]; - } + foreach (Arsse::$db->tagList(Arsse::$user->id) as $member) { + $out[] = [ + 'id' => (int) $member['id'], + 'title' => $member['name'], + ]; } return $out; } - protected function getRelationships(array $data): array { + protected function getRelationships(): array { $out = []; $sets = []; - foreach ($data as $member) { + foreach (Arsse::$db->tagSummarize(Arsse::$user->id) as $member) { if (!isset($sets[$member['id']])) { $sets[$member['id']] = []; } diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index c76d567..be34712 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -23,7 +23,6 @@ use Psr\Http\Message\ResponseInterface; use Zend\Diactoros\ServerRequest; use Zend\Diactoros\Response\JsonResponse; use Zend\Diactoros\Response\EmptyResponse; -use Phake; /** @covers \JKingWeb\Arsse\REST\Fever\API */ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { @@ -66,12 +65,13 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { self::clearData(); self::setConf(); // create a mock user manager - Arsse::$user = Phake::mock(User::class); - Phake::when(Arsse::$user)->auth->thenReturn(true); + Arsse::$user = \Phake::mock(User::class); + \Phake::when(Arsse::$user)->auth->thenReturn(true); Arsse::$user->id = "john.doe@example.com"; // create a mock database interface - Arsse::$db = Phake::mock(Database::class); - Phake::when(Arsse::$db)->begin->thenReturn(Phake::mock(Transaction::class)); + Arsse::$db = \Phake::mock(Database::class); + \Phake::when(Arsse::$db)->begin->thenReturn(\Phake::mock(Transaction::class)); + \Phake::when(Arsse::$db)->tokenLookup->thenReturn(['user' => "john.doe@example.com"]); // instantiate the handler $this->h = new API(); } @@ -89,6 +89,11 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { Arsse::$user->id = null; \Phake::when(Arsse::$db)->tokenLookup->thenThrow(new ExceptionInput("subjectMissing")); \Phake::when(Arsse::$db)->tokenLookup("fever.login", "validtoken")->thenReturn(['user' => "jane.doe@example.com"]); + // use a partial mock to test only the authentication process + $this->h = \Phake::partialMock(API::class); + \Phake::when($this->h)->processRequest->thenReturnCallback(function($out, $G, $P) { + return $out; + }); $act = $this->req($dataGet, $dataPost, "POST", null, "", $httpUser); $this->assertMessage($exp, $act); } From de615c671ad60c79073a18d9275484bf86e16cbc Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 27 Mar 2019 15:09:04 -0400 Subject: [PATCH 069/142] Tests and fixed for Fever feeds and groups --- lib/REST/Fever/API.php | 46 +++++++++++------- tests/cases/REST/Fever/TestAPI.php | 78 +++++++++++++++++++++++++----- 2 files changed, 96 insertions(+), 28 deletions(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index c5a93a3..47b4038 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -30,8 +30,8 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } public function dispatch(ServerRequestInterface $req): ResponseInterface { - $inR = $req->getQueryParams(); - $inW = $req->getParsedBody(); + $inR = $req->getQueryParams() ?? []; + $inW = $req->getParsedBody() ?? []; if (!array_key_exists("api", $inR)) { // the original would have shown the Fever UI in the absence of the "api" parameter, but we'll return 404 return new EmptyResponse(404); @@ -57,14 +57,13 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // otherwise if HTTP authentication failed or is required, deny access at the HTTP level return new EmptyResponse(401); } - // check that the user specified credentials + // produce a full response if authenticated or a basic response otherwise if ($this->logIn(strtolower($inW['api_key'] ?? ""))) { - $out['auth'] = 1; - $out = $this->processRequest($out, $inR, $inW); + $out = $this->processRequest($this->baseResponse(true), $inR, $inW); } else { - $out['auth'] = 0; + $out = $this->baseResponse(false); } - // return the result + // return the result, possibly formatted as XML return $this->formatResponse($out, $xml); break; default: @@ -73,9 +72,6 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } protected function processRequest(array $out, array $G, array $P): array { - // add base metadata - $out['last_refreshed_on_time'] = Date::transform(Arsse::$db->subscriptionRefreshed(Arsse::$user->id), "unix"); - // handle each possible parameter if (array_key_exists("feeds", $G) || array_key_exists("groups", $G)) { if (array_key_exists("groups", $G)) { $out['groups'] = $this->getGroups(); @@ -91,6 +87,18 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { return $out; } + protected function baseResponse(bool $authenticated): array { + $out = [ + 'api_version' => self::LEVEL, + 'auth' => (int) $authenticated, + ]; + if ($authenticated) { + // authenticated requests always include the most recent feed refresh + $out['last_refreshed_on_time'] = $this->getRefreshTime(); + } + return $out; + } + protected function formatResponse(array $data, bool $xml): ResponseInterface { if ($xml) { throw \Exception("Not implemented yet"); @@ -115,17 +123,21 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { return true; } + protected function getRefreshTime() { + return Date::transform(Arsse::$db->subscriptionRefreshed(Arsse::$user->id), "unix"); + } + protected function getFeeds(): array { $out = []; foreach (arsse::$db->subscriptionList(Arsse::$user->id) as $sub) { $out[] = [ - 'id' => (int) $sub['id'], - 'favicon_id' => (int) ($sub['favicon'] ? $sub['feed'] : 0), - 'title' => (string) $sub['title'], - 'url' => $sub['url'], - 'site_url' => $sub['source'], - 'is_spark' => 0, - 'lat_updated_on_time' => Date::transform($sub['edited'], "unix", "sql"), + 'id' => (int) $sub['id'], + 'favicon_id' => (int) ($sub['favicon'] ? $sub['feed'] : 0), + 'title' => (string) $sub['title'], + 'url' => $sub['url'], + 'site_url' => $sub['source'], + 'is_spark' => 0, + 'last_updated_on_time' => Date::transform($sub['edited'], "unix", "sql"), ]; } return $out; diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index be34712..272a25f 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -31,7 +31,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { return $value; } - protected function req($dataGet, $dataPost, string $method = "POST", string $type = null, string $url = "", string $user = null): ResponseInterface { + protected function req($dataGet, $dataPost = "", string $method = "POST", string $type = null, string $url = "", string $user = null): ResponseInterface { $url = "/fever/".$url; $server = [ 'REQUEST_METHOD' => $method, @@ -39,11 +39,10 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { 'HTTP_CONTENT_TYPE' => $type ?? "application/x-www-form-urlencoded", ]; $req = new ServerRequest($server, [], $url, $method, "php://memory"); - if (is_array($dataGet)) { - $req = $req->withRequestTarget($url)->withQueryParams($dataGet); - } else { - $req = $req->withRequestTarget($url."?".http_build_query((string) $dataGet, "", "&", \PHP_QUERY_RFC3986)); + if (!is_array($dataGet)) { + parse_str($dataGet, $dataGet); } + $req = $req->withRequestTarget($url)->withQueryParams($dataGet); if (is_array($dataPost)) { $req = $req->withParsedBody($dataPost); } else { @@ -72,8 +71,9 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { Arsse::$db = \Phake::mock(Database::class); \Phake::when(Arsse::$db)->begin->thenReturn(\Phake::mock(Transaction::class)); \Phake::when(Arsse::$db)->tokenLookup->thenReturn(['user' => "john.doe@example.com"]); - // instantiate the handler - $this->h = new API(); + // instantiate the handler as a partial mock to simplify testing + $this->h = \Phake::partialMock(API::class); + \Phake::when($this->h)->baseResponse->thenReturn([]); } public function tearDown() { @@ -89,8 +89,10 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { Arsse::$user->id = null; \Phake::when(Arsse::$db)->tokenLookup->thenThrow(new ExceptionInput("subjectMissing")); \Phake::when(Arsse::$db)->tokenLookup("fever.login", "validtoken")->thenReturn(['user' => "jane.doe@example.com"]); - // use a partial mock to test only the authentication process - $this->h = \Phake::partialMock(API::class); + // test only the authentication process + \Phake::when($this->h)->baseResponse->thenReturnCallback(function(bool $authenticated) { + return ['auth' => (int) $authenticated]; + }); \Phake::when($this->h)->processRequest->thenReturnCallback(function($out, $G, $P) { return $out; }); @@ -99,8 +101,8 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { } public function provideTokenAuthenticationRequests() { - $success = new JsonResponse(['api_version' => API::LEVEL, 'auth' => 1]); - $failure = new JsonResponse(['api_version' => API::LEVEL, 'auth' => 0]); + $success = new JsonResponse(['auth' => 1]); + $failure = new JsonResponse(['auth' => 0]); $denied = new EmptyResponse(401); return [ [false, true, null, [], ['api' => null], $failure], @@ -149,4 +151,58 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { [true, false, "validUser", ['api_key' => "invalidToken"], ['api' => null], $success], ]; } + + public function testListGroups() { + \Phake::when(Arsse::$db)->tagList(Arsse::$user->id)->thenReturn(new Result([ + ['id' => 1, 'name' => "Fascinating", 'subscriptions' => 2], + ['id' => 2, 'name' => "Interesting", 'subscriptions' => 2], + ['id' => 3, 'name' => "Boring", 'subscriptions' => 0], + ])); + \Phake::when(Arsse::$db)->tagSummarize(Arsse::$user->id)->thenReturn(new Result([ + ['id' => 1, 'name' => "Fascinating", 'subscription' => 1], + ['id' => 1, 'name' => "Fascinating", 'subscription' => 2], + ['id' => 2, 'name' => "Interesting", 'subscription' => 1], + ['id' => 2, 'name' => "Interesting", 'subscription' => 3], + ])); + $exp = new JsonResponse([ + 'groups' => [ + ['id' => 1, 'title' => "Fascinating"], + ['id' => 2, 'title' => "Interesting"], + ['id' => 3, 'title' => "Boring"], + ], + 'feeds_groups' => [ + ['group_id' => 1, 'feed_ids' => "1,2"], + ['group_id' => 2, 'feed_ids' => "1,3"], + ], + ]); + $act = $this->req("api&groups"); + $this->assertMessage($exp, $act); + } + + public function testListFeeds() { + \Phake::when(Arsse::$db)->subscriptionList(Arsse::$user->id)->thenReturn(new Result([ + ['id' => 1, 'feed' => 5, 'title' => "Ankh-Morpork News", 'url' => "http://example.com/feed", 'source' => "http://example.com/", 'edited' => "2019-01-01 21:12:00", 'favicon' => "http://example.com/favicon.ico"], + ['id' => 2, 'feed' => 9, 'title' => "Ook, Ook Eek Ook!", 'url' => "http://example.net/feed", 'source' => "http://example.net/", 'edited' => "1988-06-24 12:21:00", 'favicon' => ""], + ['id' => 3, 'feed' => 1, 'title' => "The Last Soul", 'url' => "http://example.org/feed", 'source' => "http://example.org/", 'edited' => "1991-08-12 03:22:00", 'favicon' => "http://example.org/favicon.ico"], + ])); + \Phake::when(Arsse::$db)->tagSummarize(Arsse::$user->id)->thenReturn(new Result([ + ['id' => 1, 'name' => "Fascinating", 'subscription' => 1], + ['id' => 1, 'name' => "Fascinating", 'subscription' => 2], + ['id' => 2, 'name' => "Interesting", 'subscription' => 1], + ['id' => 2, 'name' => "Interesting", 'subscription' => 3], + ])); + $exp = new JsonResponse([ + 'feeds' => [ + ['id' => 1, 'favicon_id' => 5, 'title' => "Ankh-Morpork News", 'url' => "http://example.com/feed", 'site_url' => "http://example.com/", 'is_spark' => 0, 'last_updated_on_time' => strtotime("2019-01-01T21:12:00Z")], + ['id' => 2, 'favicon_id' => 0, 'title' => "Ook, Ook Eek Ook!", 'url' => "http://example.net/feed", 'site_url' => "http://example.net/", 'is_spark' => 0, 'last_updated_on_time' => strtotime("1988-06-24T12:21:00Z")], + ['id' => 3, 'favicon_id' => 1, 'title' => "The Last Soul", 'url' => "http://example.org/feed", 'site_url' => "http://example.org/", 'is_spark' => 0, 'last_updated_on_time' => strtotime("1991-08-12T03:22:00Z")], + ], + 'feeds_groups' => [ + ['group_id' => 1, 'feed_ids' => "1,2"], + ['group_id' => 2, 'feed_ids' => "1,3"], + ], + ]); + $act = $this->req("api&feeds"); + $this->assertMessage($exp, $act); + } } From 5d994f3dadad6d26134678afd919de25a3837454 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 28 Mar 2019 14:54:31 -0400 Subject: [PATCH 070/142] Normalize Fever input consistently Two parameters are undocumented, but other implementations consistently accept them from clients --- lib/REST/Fever/API.php | 80 +++++++++++++++++++++++++++++++++++------- 1 file changed, 67 insertions(+), 13 deletions(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 47b4038..5dcb9b0 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -11,7 +11,7 @@ use JKingWeb\Arsse\Database; use JKingWeb\Arsse\User; use JKingWeb\Arsse\Service; use JKingWeb\Arsse\Context\Context; -use JKingWeb\Arsse\Misc\ValueInfo; +use JKingWeb\Arsse\Misc\ValueInfo as V; use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\AbstractException; use JKingWeb\Arsse\Db\ExceptionInput; @@ -26,17 +26,40 @@ use Zend\Diactoros\Response\EmptyResponse; class API extends \JKingWeb\Arsse\REST\AbstractHandler { const LEVEL = 3; + // GET parameters for which we only check presence: these will be converted to booleans + const PARAM_BOOL = ["groups", "feeds", "items", "favicons", "links", "unread_item_ids", "saved_item_ids"]; + // GET parameters which contain meaningful values + const PARAM_GET = [ + 'api' => V::T_STRING, // this parameter requires special handling + 'page' => V::T_INT, // parameter for hot links + 'range' => V::T_INT, // parameter for hot links + 'offset' => V::T_INT, // parameter for hot links + 'since_id' => V::T_INT, + 'max_id' => V::T_INT, + 'with_ids' => V::T_STRING, + 'group_ids' => V::T_STRING, // undocumented parameter for 'items' lookup + 'feed_ids' => V::T_STRING, // undocumented parameter for 'items' lookup + ]; + // POST parameters, all of which contain meaningful values + const PARAM_POST = [ + 'api_key' => V::T_STRING, + 'mark' => V::T_STRING, + 'as' => V::T_STRING, + 'id' => V::T_INT, + 'before' => V::T_DATE, + 'unread_recently_read' => V::T_BOOL, + ]; + public function __construct() { } public function dispatch(ServerRequestInterface $req): ResponseInterface { - $inR = $req->getQueryParams() ?? []; - $inW = $req->getParsedBody() ?? []; - if (!array_key_exists("api", $inR)) { + $G = $this->normalizeInputGet($req->getQueryParams() ?? []); + $P = $this->normalizeInputPost($req->getParsedBody() ?? []); + if (!isset($G['api'])) { // the original would have shown the Fever UI in the absence of the "api" parameter, but we'll return 404 return new EmptyResponse(404); } - $xml = $inR['api'] === "xml"; switch ($req->getMethod()) { case "OPTIONS": // do stuff @@ -58,32 +81,63 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { return new EmptyResponse(401); } // produce a full response if authenticated or a basic response otherwise - if ($this->logIn(strtolower($inW['api_key'] ?? ""))) { - $out = $this->processRequest($this->baseResponse(true), $inR, $inW); + if ($this->logIn(strtolower($P['api_key'] ?? ""))) { + $out = $this->processRequest($this->baseResponse(true), $G, $P); } else { $out = $this->baseResponse(false); } // return the result, possibly formatted as XML - return $this->formatResponse($out, $xml); - break; + return $this->formatResponse($out, ($G['api'] === "xml")); default: return new EmptyResponse(405, ['Allow' => "OPTIONS,POST"]); } } + protected function normalizeInputGet(array $data): array { + $out = []; + if (array_key_exists("api", $data)) { + // the "api" parameter must be handled specially as it a string, but null has special meaning + $data['api'] = $data['api'] ?? "json"; + } + foreach (self::PARAM_BOOL as $p) { + // first handle all the boolean parameters + $out[$p] = array_key_exists($p, $data); + } + foreach (self::PARAM_GET as $p => $t) { + $out[$p] = V::normalize($data[$p] ?? null, $t | V::M_DROP, "unix"); + } + return $out; + } + + protected function normalizeInputPost(array $data): array { + $out = []; + foreach (self::PARAM_POST as $p => $t) { + $out[$p] = V::normalize($data[$p] ?? null, $t | V::M_DROP, "unix"); + } + return $out; + } + protected function processRequest(array $out, array $G, array $P): array { - if (array_key_exists("feeds", $G) || array_key_exists("groups", $G)) { - if (array_key_exists("groups", $G)) { + if ($G['feeds'] || $G['groups']) { + if ($G['groups']) { $out['groups'] = $this->getGroups(); } - if (array_key_exists("feeds", $G)) { + if ($G['feeds']) { $out['feeds'] = $this->getFeeds(); } $out['feeds_groups'] = $this->getRelationships(); } - if (array_key_exists("favicons", $G)) { + if ($G['favicons']) { # deal with favicons } + if ($G['items']) { + $out['items'] = $this->getItems($G); + $out['total_items'] = Arsse::$db->articleCount(Arsse::$user->id); + } + if ($G['links']) { + // TODO: implement hot links + $out['inks'] = []; + } return $out; } From 25b7b47e0a9ca1448582aad7ea2d7ea03bd15699 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 28 Mar 2019 21:53:04 -0400 Subject: [PATCH 071/142] Prototype OPML exporter --- lib/ImportExport/OPML.php | 59 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 lib/ImportExport/OPML.php diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php new file mode 100644 index 0000000..e3053a0 --- /dev/null +++ b/lib/ImportExport/OPML.php @@ -0,0 +1,59 @@ + null]; + $tags = []; + $document = new \DOMDocument("1.0", "utf-8"); + $document->formatOutput = true; + $document->appendChild($document->createElement("opml")); + $document->documentElement->setAttribute("version", "2.0"); + $document->documentElement->appendChild($document->createElement("head")); + // create the "root folder" node (the body node, in OPML terms) + $folders[0] = $document->createElement("body"); + $transaction = Arsse::$db->begin(); + foreach (Arsse::$db->tagSummarize($user) as $r) { + $sub = $r['subscription']; + $tag = $r['name']; + $tag = str_replace(",", "", $tag); + if (!isset($tags[$sub])) { + $tags[$sub] = []; + } + $tags[$sub][] = $tag; + } + if (!$flat) { + foreach (Arsse::$db->folderList($user) as $r) { + $parents[$r['id']] = $r['parent'] ?? 0; + $el = $document->createElement("outline"); + $el->setAttribute("text", $r['name']); + $folders[$r['id']] = $el; + } + } + foreach (Arsse::$db->subscriptionList($user) as $r) { + $el = $document->createElement(("outline")); + $el->setAttribute("text", $r['title']); + $el->setAttribute("type", "rss"); + $el->setAttribute("xmlUrl", $r['url']); + if (sizeof($tags[$r['id']])) { + $el->setAttribute("category", implode(",", $tags[$r['id']])); + } + ($folders[$r['folder'] ?? 0] ?? $folders[0])->appendChild($el); + } + $transaction->rollback(); + foreach ($folders as $id => $el) { + $parent = $parents[$id] ?? $document->documentElement; + $parent->appendChild($el); + } + // return the serialization + return $document->saveXML(); + } +} From d63edf541f77a736715039ec6689b01193993c7b Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 29 Mar 2019 09:02:39 -0400 Subject: [PATCH 072/142] Insert folders into OPML before subscriptions --- lib/ImportExport/OPML.php | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php index e3053a0..032d753 100644 --- a/lib/ImportExport/OPML.php +++ b/lib/ImportExport/OPML.php @@ -10,9 +10,10 @@ use JKingWeb\Arsse\Arsse; class OPML { public function export(string $user, bool $flat = false): string { + $tags = []; $folders = []; $parents = [0 => null]; - $tags = []; + // create a base document $document = new \DOMDocument("1.0", "utf-8"); $document->formatOutput = true; $document->appendChild($document->createElement("opml")); @@ -20,10 +21,13 @@ class OPML { $document->documentElement->appendChild($document->createElement("head")); // create the "root folder" node (the body node, in OPML terms) $folders[0] = $document->createElement("body"); + // begin a transaction for read isolation $transaction = Arsse::$db->begin(); + // gather up the list of tags for each subscription foreach (Arsse::$db->tagSummarize($user) as $r) { $sub = $r['subscription']; $tag = $r['name']; + // strip out any commas in the tag name; sadly this is lossy as OPML has no escape mechanism $tag = str_replace(",", "", $tag); if (!isset($tags[$sub])) { $tags[$sub] = []; @@ -31,28 +35,36 @@ class OPML { $tags[$sub][] = $tag; } if (!$flat) { + // unless the output is requested flat, gather up the list of folders, using their database IDs as array indices foreach (Arsse::$db->folderList($user) as $r) { + // note the index of its parent folder for later tree construction $parents[$r['id']] = $r['parent'] ?? 0; + // create a DOM node for each folder; we don't insert it yet $el = $document->createElement("outline"); $el->setAttribute("text", $r['name']); $folders[$r['id']] = $el; } + // insert each folder into its parent node; for the root folder the parent is the document root node + foreach ($folders as $id => $el) { + $parent = $parents[$id] ?? $document->documentElement; + $parent->appendChild($el); + } } + // create a DOM node for each subscription and insert them directly into their folder DOM node foreach (Arsse::$db->subscriptionList($user) as $r) { $el = $document->createElement(("outline")); $el->setAttribute("text", $r['title']); $el->setAttribute("type", "rss"); $el->setAttribute("xmlUrl", $r['url']); + // include the category attribute only if there are tags if (sizeof($tags[$r['id']])) { $el->setAttribute("category", implode(",", $tags[$r['id']])); } + // if flat output was requested subscriptions are inserted into the root folder ($folders[$r['folder'] ?? 0] ?? $folders[0])->appendChild($el); } + // release the transaction $transaction->rollback(); - foreach ($folders as $id => $el) { - $parent = $parents[$id] ?? $document->documentElement; - $parent->appendChild($el); - } // return the serialization return $document->saveXML(); } From 17fd9093352fc9bd72a78f9615c8db9cfdb3fde6 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 29 Mar 2019 10:15:30 -0400 Subject: [PATCH 073/142] Add DOM extension as a direct dependency Previously it was already a dependency of PicoFeed, so there's effectively no change --- README.md | 4 ++-- composer.json | 1 + composer.lock | 17 +++++++++-------- vendor-bin/csfixer/composer.lock | 10 +++++----- vendor-bin/phpunit/composer.lock | 10 +++++----- 5 files changed, 22 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index ab7dc2e..08dab43 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,8 @@ The Arsse has the following requirements: - A Linux server utilizing systemd and Nginx (tested on Ubuntu 16.04) - PHP 7.0.7 or later with the following extensions: - - [intl](http://php.net/manual/en/book.intl.php), [json](http://php.net/manual/en/book.json.php), [hash](http://php.net/manual/en/book.hash.php), and [pcre](http://php.net/manual/en/book.pcre.php) - - [dom](http://php.net/manual/en/book.dom.php), [simplexml](http://php.net/manual/en/book.simplexml.php), and [iconv](http://php.net/manual/en/book.iconv.php) (for picoFeed) + - [intl](http://php.net/manual/en/book.intl.php), [json](http://php.net/manual/en/book.json.php), [hash](http://php.net/manual/en/book.hash.php), and [dom](http://php.net/manual/en/book.dom.php) + - [simplexml](http://php.net/manual/en/book.simplexml.php), and [iconv](http://php.net/manual/en/book.iconv.php) (for picoFeed) - One of: - [sqlite3](http://php.net/manual/en/book.sqlite3.php) or [pdo_sqlite](http://php.net/manual/en/ref.pdo-sqlite.php) for SQLite databases - [pgsql](http://php.net/manual/en/book.pgsql.php) or [pdo_pgsql](http://php.net/manual/en/ref.pdo-pgsql.php) for PostgreSQL 10 or later databases diff --git a/composer.json b/composer.json index 0f5570c..0669d65 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ "ext-intl": "*", "ext-json": "*", "ext-hash": "*", + "ext-dom": "*", "p3k/picofeed": "0.1.*", "hosteurope/password-generator": "^1.0", "docopt/docopt": "^1.0", diff --git a/composer.lock b/composer.lock index b5e5d38..986e47c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d7a6a00be3d97c11d09ec4d4e56d36e0", + "content-hash": "f61a02cd168914d91847b89dcd00d464", "packages": [ { "name": "docopt/docopt", @@ -354,16 +354,16 @@ "packages-dev": [ { "name": "bamarni/composer-bin-plugin", - "version": "v1.2.0", + "version": "v1.3.0", "source": { "type": "git", "url": "https://github.com/bamarni/composer-bin-plugin.git", - "reference": "62fef740245a85f00665e81ea8f0aa0b72afe6e7" + "reference": "67f9d314dc7ecf7245b8637906e151ccc62b8d24" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bamarni/composer-bin-plugin/zipball/62fef740245a85f00665e81ea8f0aa0b72afe6e7", - "reference": "62fef740245a85f00665e81ea8f0aa0b72afe6e7", + "url": "https://api.github.com/repos/bamarni/composer-bin-plugin/zipball/67f9d314dc7ecf7245b8637906e151ccc62b8d24", + "reference": "67f9d314dc7ecf7245b8637906e151ccc62b8d24", "shasum": "" }, "require": { @@ -371,7 +371,7 @@ }, "require-dev": { "composer/composer": "dev-master", - "symfony/console": "^2.5 || ^3.0" + "symfony/console": "^2.5 || ^3.0 || ^4.0" }, "type": "composer-plugin", "extra": { @@ -389,7 +389,7 @@ "license": [ "MIT" ], - "time": "2017-09-11T13:13:58+00:00" + "time": "2019-03-17T12:38:04+00:00" } ], "aliases": [], @@ -401,7 +401,8 @@ "php": "^7.0", "ext-intl": "*", "ext-json": "*", - "ext-hash": "*" + "ext-hash": "*", + "ext-dom": "*" }, "platform-dev": [] } diff --git a/vendor-bin/csfixer/composer.lock b/vendor-bin/csfixer/composer.lock index 155169e..e48cd3d 100644 --- a/vendor-bin/csfixer/composer.lock +++ b/vendor-bin/csfixer/composer.lock @@ -114,16 +114,16 @@ }, { "name": "doctrine/annotations", - "version": "v1.6.0", + "version": "v1.6.1", "source": { "type": "git", "url": "https://github.com/doctrine/annotations.git", - "reference": "c7f2050c68a9ab0bdb0f98567ec08d80ea7d24d5" + "reference": "53120e0eb10355388d6ccbe462f1fea34ddadb24" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/annotations/zipball/c7f2050c68a9ab0bdb0f98567ec08d80ea7d24d5", - "reference": "c7f2050c68a9ab0bdb0f98567ec08d80ea7d24d5", + "url": "https://api.github.com/repos/doctrine/annotations/zipball/53120e0eb10355388d6ccbe462f1fea34ddadb24", + "reference": "53120e0eb10355388d6ccbe462f1fea34ddadb24", "shasum": "" }, "require": { @@ -178,7 +178,7 @@ "docblock", "parser" ], - "time": "2017-12-06T07:11:42+00:00" + "time": "2019-03-25T19:12:02+00:00" }, { "name": "doctrine/lexer", diff --git a/vendor-bin/phpunit/composer.lock b/vendor-bin/phpunit/composer.lock index 016ad85..32cf644 100644 --- a/vendor-bin/phpunit/composer.lock +++ b/vendor-bin/phpunit/composer.lock @@ -835,16 +835,16 @@ }, { "name": "phpunit/phpunit", - "version": "7.5.7", + "version": "7.5.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "eb343b86753d26de07ecba7868fa983104361948" + "reference": "c29c0525cf4572c11efe1db49a8b8aee9dfac58a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/eb343b86753d26de07ecba7868fa983104361948", - "reference": "eb343b86753d26de07ecba7868fa983104361948", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c29c0525cf4572c11efe1db49a8b8aee9dfac58a", + "reference": "c29c0525cf4572c11efe1db49a8b8aee9dfac58a", "shasum": "" }, "require": { @@ -915,7 +915,7 @@ "testing", "xunit" ], - "time": "2019-03-16T07:31:17+00:00" + "time": "2019-03-26T13:23:54+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", From 35e79d53a9f3a9bfa7a1df959d8aa13fd6051258 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 30 Mar 2019 10:01:12 -0400 Subject: [PATCH 074/142] OPML export fixes, with tests --- lib/ImportExport/OPML.php | 14 ++-- tests/cases/ImportExport/TestOPML.php | 98 +++++++++++++++++++++++++++ tests/phpunit.xml | 3 + 3 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 tests/cases/ImportExport/TestOPML.php diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php index 032d753..6c650c8 100644 --- a/lib/ImportExport/OPML.php +++ b/lib/ImportExport/OPML.php @@ -44,20 +44,20 @@ class OPML { $el->setAttribute("text", $r['name']); $folders[$r['id']] = $el; } - // insert each folder into its parent node; for the root folder the parent is the document root node - foreach ($folders as $id => $el) { - $parent = $parents[$id] ?? $document->documentElement; - $parent->appendChild($el); - } + } + // insert each folder into its parent node; for the root folder the parent is the document root node + foreach ($folders as $id => $el) { + $parent = $folders[$parents[$id]] ?? $document->documentElement; + $parent->appendChild($el); } // create a DOM node for each subscription and insert them directly into their folder DOM node foreach (Arsse::$db->subscriptionList($user) as $r) { $el = $document->createElement(("outline")); - $el->setAttribute("text", $r['title']); $el->setAttribute("type", "rss"); + $el->setAttribute("text", $r['title']); $el->setAttribute("xmlUrl", $r['url']); // include the category attribute only if there are tags - if (sizeof($tags[$r['id']])) { + if (isset($tags[$r['id']]) && sizeof($tags[$r['id']])) { $el->setAttribute("category", implode(",", $tags[$r['id']])); } // if flat output was requested subscriptions are inserted into the root folder diff --git a/tests/cases/ImportExport/TestOPML.php b/tests/cases/ImportExport/TestOPML.php new file mode 100644 index 0000000..387400c --- /dev/null +++ b/tests/cases/ImportExport/TestOPML.php @@ -0,0 +1,98 @@ + */ +class TestOPML extends \JKingWeb\Arsse\Test\AbstractTest { + protected $folders = [ + ['id' => 5, 'parent' => 3, 'children' => 0, 'feeds' => 1, 'name' => "Local"], + ['id' => 6, 'parent' => 3, 'children' => 0, 'feeds' => 2, 'name' => "National"], + ['id' => 4, 'parent' => null, 'children' => 0, 'feeds' => 0, 'name' => "Photography"], + ['id' => 3, 'parent' => null, 'children' => 2, 'feeds' => 0, 'name' => "Politics"], + ['id' => 2, 'parent' => 1, 'children' => 0, 'feeds' => 1, 'name' => "Rocketry"], + ['id' => 1, 'parent' => null, 'children' => 1, 'feeds' => 1, 'name' => "Science"], + ]; + protected $subscriptions = [ + ['id' => 3, 'folder' => 1, 'top_folder' => 1, 'unread' => 2, 'updated' => "2016-05-23 06:40:02", 'err_msg' => 'argh', 'title' => 'Ars Technica', 'url' => "http://example.com/3", 'favicon' => 'http://example.com/3.png'], + ['id' => 4, 'folder' => 6, 'top_folder' => 3, 'unread' => 6, 'updated' => "2017-10-09 15:58:34", 'err_msg' => '', 'title' => 'CBC News', 'url' => "http://example.com/4", 'favicon' => 'http://example.com/4.png'], + ['id' => 6, 'folder' => null, 'top_folder' => null, 'unread' => 0, 'updated' => "2010-02-12 20:08:47", 'err_msg' => '', 'title' => 'Eurogamer', 'url' => "http://example.com/6", 'favicon' => 'http://example.com/6.png'], + ['id' => 1, 'folder' => 2, 'top_folder' => 1, 'unread' => 5, 'updated' => "2017-09-15 22:54:16", 'err_msg' => '', 'title' => 'NASA JPL', 'url' => "http://example.com/1", 'favicon' => null], + ['id' => 5, 'folder' => 6, 'top_folder' => 3, 'unread' => 12, 'updated' => "2017-07-07 17:07:17", 'err_msg' => '', 'title' => 'Ottawa Citizen', 'url' => "http://example.com/5", 'favicon' => ''], + ['id' => 2, 'folder' => 5, 'top_folder' => 3, 'unread' => 10, 'updated' => "2011-11-11 11:11:11", 'err_msg' => 'oops', 'title' => 'Toronto Star', 'url' => "http://example.com/2", 'favicon' => 'http://example.com/2.png'], + ]; + protected $tags = [ + ['id' => 1, 'name' => "Canada", 'subscription' => 2], + ['id' => 1, 'name' => "Canada", 'subscription' => 4], + ['id' => 1, 'name' => "Canada", 'subscription' => 5], + ['id' => 2, 'name' => "Politics", 'subscription' => 4], + ['id' => 2, 'name' => "Politics", 'subscription' => 5], + ['id' => 3, 'name' => "Science, etc", 'subscription' => 1], + ['id' => 3, 'name' => "Science, etc", 'subscription' => 3], + // Eurogamer is untagged + ]; + protected $serialization = << + + + + + + + + + + + + + + + + + + + + + + +OPML_EXPORT_SERIALIZATION; + protected $serializationFlat = << + + + + + + + + + + + +OPML_EXPORT_SERIALIZATION; + + public function setUp() { + Arsse::$db = \Phake::mock(\JKingWeb\Arsse\Database::class); + } + + public function testExportToOpml() { + \Phake::when(Arsse::$db)->folderList("john.doe@example.com")->thenReturn(new Result($this->folders)); + \Phake::when(Arsse::$db)->subscriptionList("john.doe@example.com")->thenReturn(new Result($this->subscriptions)); + \Phake::when(Arsse::$db)->tagSummarize("john.doe@example.com")->thenReturn(new Result($this->tags)); + $this->assertXmlStringEqualsXmlString($this->serialization, (new OPML)->export("john.doe@example.com")); + } + + public function testExportToFlatOpml() { + \Phake::when(Arsse::$db)->folderList("john.doe@example.com")->thenReturn(new Result($this->folders)); + \Phake::when(Arsse::$db)->subscriptionList("john.doe@example.com")->thenReturn(new Result($this->subscriptions)); + \Phake::when(Arsse::$db)->tagSummarize("john.doe@example.com")->thenReturn(new Result($this->tags)); + $this->assertXmlStringEqualsXmlString($this->serializationFlat, (new OPML)->export("john.doe@example.com", true)); + } +} diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 7c698ab..fd5429f 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -113,5 +113,8 @@ cases/Service/TestService.php cases/CLI/TestCLI.php + + cases/ImportExport/TestOPML.php + From deea294f8a70329cc4ab5ade952920af12e3c3b2 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 1 Apr 2019 16:54:14 -0400 Subject: [PATCH 075/142] Add export-to-file wrapper for OPML --- lib/AbstractException.php | 2 + lib/ImportExport/Exception.php | 10 +++ lib/ImportExport/OPML.php | 14 ++++ locale/en.php | 10 +++ tests/cases/ImportExport/TestOPML.php | 9 +++ tests/cases/ImportExport/TestOPMLFile.php | 82 +++++++++++++++++++++++ tests/phpunit.xml | 1 + 7 files changed, 128 insertions(+) create mode 100644 lib/ImportExport/Exception.php create mode 100644 tests/cases/ImportExport/TestOPMLFile.php diff --git a/lib/AbstractException.php b/lib/AbstractException.php index a524da6..e4f22a9 100644 --- a/lib/AbstractException.php +++ b/lib/AbstractException.php @@ -86,6 +86,8 @@ abstract class AbstractException extends \Exception { "Feed/Exception.xmlEntity" => 10512, "Feed/Exception.subscriptionNotFound" => 10521, "Feed/Exception.unsupportedFeedFormat" => 10522, + "ImportExport/Exception.fileUnwritable" => 10604, + "ImportExport/Exception.fileUncreatable" => 10605, ]; public function __construct(string $msgID = "", $vars = null, \Throwable $e = null) { diff --git a/lib/ImportExport/Exception.php b/lib/ImportExport/Exception.php new file mode 100644 index 0000000..888cfca --- /dev/null +++ b/lib/ImportExport/Exception.php @@ -0,0 +1,10 @@ +exists($user)) { + throw new UserException("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); + } $tags = []; $folders = []; $parents = [0 => null]; @@ -68,4 +72,14 @@ class OPML { // return the serialization return $document->saveXML(); } + + public function exportFile(string $file, string $user, bool $flat = false): bool { + $data = $this->export($user, $flat); + if (!@file_put_contents($file, $data)) { + // if it fails throw an exception + $err = file_exists($file) ? "fileUnwritable" : "fileUncreatable"; + throw new Exception($err, ['file' => $file, 'format' => str_replace(__NAMESPACE__."\\", "", __CLASS__)]); + } + return true; + } } diff --git a/locale/en.php b/locale/en.php index ddbf118..a9fa045 100644 --- a/locale/en.php +++ b/locale/en.php @@ -155,4 +155,14 @@ return [ 'Exception.JKingWeb/Arsse/Feed/Exception.xmlEntity' => 'Refused to parse feed "{url}" because it contains an XXE attack', 'Exception.JKingWeb/Arsse/Feed/Exception.subscriptionNotFound' => 'Unable to find a feed at location "{url}"', 'Exception.JKingWeb/Arsse/Feed/Exception.unsupportedFeedFormat' => 'Feed "{url}" is of an unsupported format', + 'Exception.JKingWeb/Arsse/ImportExport/Exception.fileUncreatable' => + 'Insufficient permissions to write {type, select, + OPML {OPML} + other {"{type}"} + } export to file "{file}"', + 'Exception.JKingWeb/Arsse/ImportExport/Exception.fileUnwritable' => + 'Insufficient permissions to write {type, select, + OPML {OPML} + other {"{type}"} + } export to existing file "{file}"', ]; diff --git a/tests/cases/ImportExport/TestOPML.php b/tests/cases/ImportExport/TestOPML.php index 387400c..2c8d7d2 100644 --- a/tests/cases/ImportExport/TestOPML.php +++ b/tests/cases/ImportExport/TestOPML.php @@ -79,7 +79,10 @@ OPML_EXPORT_SERIALIZATION; OPML_EXPORT_SERIALIZATION; public function setUp() { + self::clearData(); Arsse::$db = \Phake::mock(\JKingWeb\Arsse\Database::class); + Arsse::$user = \Phake::mock(\JKingWeb\Arsse\User::class); + \Phake::when(Arsse::$user)->exists->thenReturn(true); } public function testExportToOpml() { @@ -95,4 +98,10 @@ OPML_EXPORT_SERIALIZATION; \Phake::when(Arsse::$db)->tagSummarize("john.doe@example.com")->thenReturn(new Result($this->tags)); $this->assertXmlStringEqualsXmlString($this->serializationFlat, (new OPML)->export("john.doe@example.com", true)); } + + public function testExportToOpmlAMissingUser() { + \Phake::when(Arsse::$user)->exists->thenReturn(false); + $this->assertException("doesNotExist", "User"); + (new OPML)->export("john.doe@example.com"); + } } diff --git a/tests/cases/ImportExport/TestOPMLFile.php b/tests/cases/ImportExport/TestOPMLFile.php new file mode 100644 index 0000000..ecb601d --- /dev/null +++ b/tests/cases/ImportExport/TestOPMLFile.php @@ -0,0 +1,82 @@ + */ +class TestOPMLFile extends \JKingWeb\Arsse\Test\AbstractTest { + protected $vfs; + protected $path; + protected $opml; + + public function setUp() { + self::clearData(); + // create a mock OPML processor with stubbed underlying import/export routines + $this->opml = \Phake::partialMock(OPML::class); + \Phake::when($this->opml)->export->thenReturn("OPML_FILE"); + $this->vfs = vfsStream::setup("root", null, [ + 'exportGoodFile' => "", + 'exportGoodDir' => [], + 'exportBadFile' => "", + 'exportBadDir' => [], + ]); + $this->path = $this->vfs->url()."/"; + // make the "bad" entries inaccessible + chmod($this->path."exportBadFile", 0000); + chmod($this->path."exportBadDir", 0000); + } + + public function tearDown() { + $this->path = null; + $this->vfs = null; + $this->opml = null; + self::clearData(); + } + + /** @dataProvider provideFileExports */ + public function testExportOpmlToAFile(string $file, string $user, bool $flat, $exp) { + $path = $this->path.$file; + try { + if ($exp instanceof \JKingWeb\Arsse\AbstractException) { + $this->assertException($exp); + $this->opml->exportFile($path, $user, $flat); + } else { + $this->assertSame($exp, $this->opml->exportFile($path, $user, $flat)); + $this->assertSame("OPML_FILE", $this->vfs->getChild($file)->getContent()); + } + } finally { + \Phake::verify($this->opml)->export($user, $flat); + } + } + + public function provideFileExports() { + $createException = new Exception("fileUncreatable"); + $writeException = new Exception("fileUnwritable"); + return [ + ["exportGoodFile", "john.doe@example.com", true, true], + ["exportGoodFile", "john.doe@example.com", false, true], + ["exportGoodFile", "jane.doe@example.com", true, true], + ["exportGoodFile", "jane.doe@example.com", false, true], + ["exportGoodDir/file", "john.doe@example.com", true, true], + ["exportGoodDir/file", "john.doe@example.com", false, true], + ["exportGoodDir/file", "jane.doe@example.com", true, true], + ["exportGoodDir/file", "jane.doe@example.com", false, true], + ["exportBadFile", "john.doe@example.com", true, $writeException], + ["exportBadFile", "john.doe@example.com", false, $writeException], + ["exportBadFile", "jane.doe@example.com", true, $writeException], + ["exportBadFile", "jane.doe@example.com", false, $writeException], + ["exportBadDir/file", "john.doe@example.com", true, $createException], + ["exportBadDir/file", "john.doe@example.com", false, $createException], + ["exportBadDir/file", "jane.doe@example.com", true, $createException], + ["exportBadDir/file", "jane.doe@example.com", false, $createException], + ]; + } +} diff --git a/tests/phpunit.xml b/tests/phpunit.xml index fd5429f..6ad94f3 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -115,6 +115,7 @@ cases/ImportExport/TestOPML.php + cases/ImportExport/TestOPMLFile.php From 77efaa7b416fe539250bcafe47a67e9afc08a905 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 1 Apr 2019 17:24:19 -0400 Subject: [PATCH 076/142] CLI command for exporting OPML and sundry cleanup --- lib/CLI.php | 53 +++++++++++++++++++------------------ tests/cases/CLI/TestCLI.php | 50 ++++++++++++++++++++++++++++------ 2 files changed, 69 insertions(+), 34 deletions(-) diff --git a/lib/CLI.php b/lib/CLI.php index 9693698..218e3d3 100644 --- a/lib/CLI.php +++ b/lib/CLI.php @@ -7,6 +7,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse; use JKingWeb\Arsse\REST\Fever\User as Fever; +use JKingWeb\Arsse\ImportExport\OPML; class CLI { const USAGE = << [--oldpass=] [--fever] arsse.php user auth [--fever] + arsse.php export [] [-f | --flat] arsse.php --version arsse.php --help | -h @@ -54,6 +56,12 @@ USAGE_TEXT; return true; } + protected function resolveFile($file, string $mode): string { + // TODO: checking read/write permissions on the provided path may be useful + $stdinOrStdout = in_array($mode, ["r", "r+"]) ? "php://input" : "php://output"; + return ($file === "-" ? null : $file) ?? $stdinOrStdout; + } + public function dispatch(array $argv = null) { $argv = $argv ?? $_SERVER['argv']; $argv0 = array_shift($argv); @@ -62,7 +70,12 @@ USAGE_TEXT; 'help' => false, ]); try { - switch ($this->command(["--help", "--version", "daemon", "feed refresh", "feed refresh-all", "conf save-defaults", "user"], $args)) { + $cmd = $this->command(["--help", "--version", "daemon", "feed refresh", "feed refresh-all", "conf save-defaults", "user", "export"], $args); + if ($cmd && !in_array($cmd, ["--help", "--version", "conf save-defaults"])) { + // only certain commands don't require configuration to be loaded + $this->loadConf(); + } + switch ($cmd) { case "--help": echo $this->usage($argv0).\PHP_EOL; return 0; @@ -70,23 +83,22 @@ USAGE_TEXT; echo Arsse::VERSION.\PHP_EOL; return 0; case "daemon": - $this->loadConf(); - $this->getService()->watch(true); + $this->getInstance(Service::class)->watch(true); return 0; case "feed refresh": - $this->loadConf(); return (int) !Arsse::$db->feedUpdate((int) $args[''], true); case "feed refresh-all": - $this->loadConf(); - $this->getService()->watch(false); + $this->getInstance(Service::class)->watch(false); return 0; case "conf save-defaults": - $file = $args['']; - $file = ($file === "-" ? null : $file) ?? "php://output"; - return (int) !($this->getConf())->exportFile($file, true); + $file = $this->resolveFile($args[''], "w"); + return (int) !$this->getInstance(Conf::class)->exportFile($file, true); case "user": - $this->loadConf(); return $this->userManage($args); + case "export": + $u = $args['']; + $file = $this->resolveFile($args[''], "w"); + return (int) !$this->getInstance(OPML::class)->exportFile($file, $u, $args['--flat']); } } catch (AbstractException $e) { $this->logError($e->getMessage()); @@ -99,19 +111,8 @@ USAGE_TEXT; fwrite(STDERR, $msg.\PHP_EOL); } - /** @codeCoverageIgnore */ - protected function getService(): Service { - return new Service; - } - - /** @codeCoverageIgnore */ - protected function getConf(): Conf { - return new Conf; - } - - /** @codeCoverageIgnore */ - protected function getFever(): Fever { - return new Fever; + protected function getInstance(string $class) { + return new $class; } protected function userManage($args): int { @@ -120,7 +121,7 @@ USAGE_TEXT; return $this->userAddOrSetPassword("add", $args[""], $args[""]); case "set-pass": if ($args['--fever']) { - $passwd = $this->getFever()->register($args[""], $args[""]); + $passwd = $this->getInstance(Fever::class)->register($args[""], $args[""]); if (is_null($args[""])) { echo $passwd.\PHP_EOL; } @@ -130,7 +131,7 @@ USAGE_TEXT; } case "unset-pass": if ($args['--fever']) { - $this->getFever()->unregister($args[""]); + $this->getInstance(Fever::class)->unregister($args[""]); } else { Arsse::$user->passwordUnset($args[""], $args["--oldpass"]); } @@ -162,7 +163,7 @@ USAGE_TEXT; } protected function userAuthenticate(string $user, string $password, bool $fever = false): int { - $result = $fever ? $this->getFever()->authenticate($user, $password) : Arsse::$user->auth($user, $password); + $result = $fever ? $this->getInstance(Fever::class)->authenticate($user, $password) : Arsse::$user->auth($user, $password); if ($result) { echo Arsse::$lang->msg("CLI.Auth.Success").\PHP_EOL; return 0; diff --git a/tests/cases/CLI/TestCLI.php b/tests/cases/CLI/TestCLI.php index 3f1c3d3..56202d9 100644 --- a/tests/cases/CLI/TestCLI.php +++ b/tests/cases/CLI/TestCLI.php @@ -13,6 +13,7 @@ use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Service; use JKingWeb\Arsse\CLI; use JKingWeb\Arsse\REST\Fever\User as FeverUser; +use JKingWeb\Arsse\ImportExport\OPML; use Phake; /** @covers \JKingWeb\Arsse\CLI */ @@ -68,21 +69,21 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { public function testStartTheDaemon() { $srv = Phake::mock(Service::class); Phake::when($srv)->watch->thenReturn(new \DateTimeImmutable); - Phake::when($this->cli)->getService->thenReturn($srv); + Phake::when($this->cli)->getInstance(Service::class)->thenReturn($srv); $this->assertConsole($this->cli, "arsse.php daemon", 0); $this->assertLoaded(true); Phake::verify($srv)->watch(true); - Phake::verify($this->cli)->getService; + Phake::verify($this->cli)->getInstance(Service::class); } public function testRefreshAllFeeds() { $srv = Phake::mock(Service::class); Phake::when($srv)->watch->thenReturn(new \DateTimeImmutable); - Phake::when($this->cli)->getService->thenReturn($srv); + Phake::when($this->cli)->getInstance(Service::class)->thenReturn($srv); $this->assertConsole($this->cli, "arsse.php feed refresh-all", 0); $this->assertLoaded(true); Phake::verify($srv)->watch(false); - Phake::verify($this->cli)->getService; + Phake::verify($this->cli)->getInstance(Service::class); } /** @dataProvider provideFeedUpdates */ @@ -108,7 +109,7 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when($conf)->exportFile("php://output", true)->thenReturn(true); Phake::when($conf)->exportFile("good.conf", true)->thenReturn(true); Phake::when($conf)->exportFile("bad.conf", true)->thenThrow(new \JKingWeb\Arsse\Conf\Exception("fileUnwritable")); - Phake::when($this->cli)->getConf->thenReturn($conf); + Phake::when($this->cli)->getInstance(Conf::class)->thenReturn($conf); $this->assertConsole($this->cli, $cmd, $exitStatus); $this->assertLoaded(false); Phake::verify($conf)->exportFile($file, true); @@ -179,7 +180,7 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { \Phake::when($fever)->authenticate->thenReturn(false); \Phake::when($fever)->authenticate("john.doe@example.com", "ashalla")->thenReturn(true); \Phake::when($fever)->authenticate("jane.doe@example.com", "thx1138")->thenReturn(true); - \Phake::when($this->cli)->getFever->thenReturn($fever); + \Phake::when($this->cli)->getInstance(FeverUser::class)->thenReturn($fever); $this->assertConsole($this->cli, $cmd, $exitStatus, $output); } @@ -234,7 +235,7 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { Arsse::$user->method("passwordSet")->will($this->returnCallback($passwordChange)); $fever = \Phake::mock(FeverUser::class); \Phake::when($fever)->register->thenReturnCallback($passwordChange); - \Phake::when($this->cli)->getFever->thenReturn($fever); + \Phake::when($this->cli)->getInstance(FeverUser::class)->thenReturn($fever); $this->assertConsole($this->cli, $cmd, $exitStatus, $output); } @@ -264,7 +265,7 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { Arsse::$user->method("passwordUnset")->will($this->returnCallback($passwordClear)); $fever = \Phake::mock(FeverUser::class); \Phake::when($fever)->unregister->thenReturnCallback($passwordClear); - \Phake::when($this->cli)->getFever->thenReturn($fever); + \Phake::when($this->cli)->getInstance(FeverUser::class)->thenReturn($fever); $this->assertConsole($this->cli, $cmd, $exitStatus, $output); } @@ -276,4 +277,37 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { ["arsse.php user unset-pass jane.doe@example.com --fever", 10402, ""], ]; } + + /** @dataProvider provideOpmlExports */ + public function testExportToOpml(string $cmd, int $exitStatus, string $file, string $user, bool $flat) { + $opml = Phake::mock(OPML::class); + Phake::when($opml)->exportFile("php://output", $user, $flat)->thenReturn(true); + Phake::when($opml)->exportFile("good.opml", $user, $flat)->thenReturn(true); + Phake::when($opml)->exportFile("bad.opml", $user, $flat)->thenThrow(new \JKingWeb\Arsse\ImportExport\Exception("fileUnwritable")); + Phake::when($this->cli)->getInstance(OPML::class)->thenReturn($opml); + $this->assertConsole($this->cli, $cmd, $exitStatus); + $this->assertLoaded(true); + Phake::verify($opml)->exportFile($file, $user, $flat); + } + + public function provideOpmlExports() { + return [ + ["arsse.php export john.doe@example.com", 0, "php://output", "john.doe@example.com", false], + ["arsse.php export john.doe@example.com -", 0, "php://output", "john.doe@example.com", false], + ["arsse.php export john.doe@example.com good.opml", 0, "good.opml", "john.doe@example.com", false], + ["arsse.php export john.doe@example.com bad.opml", 10604, "bad.opml", "john.doe@example.com", false], + ["arsse.php export john.doe@example.com --flat", 0, "php://output", "john.doe@example.com", true], + ["arsse.php export john.doe@example.com - --flat", 0, "php://output", "john.doe@example.com", true], + ["arsse.php export --flat john.doe@example.com good.opml", 0, "good.opml", "john.doe@example.com", true], + ["arsse.php export john.doe@example.com bad.opml --flat", 10604, "bad.opml", "john.doe@example.com", true], + ["arsse.php export jane.doe@example.com", 0, "php://output", "jane.doe@example.com", false], + ["arsse.php export jane.doe@example.com -", 0, "php://output", "jane.doe@example.com", false], + ["arsse.php export jane.doe@example.com good.opml", 0, "good.opml", "jane.doe@example.com", false], + ["arsse.php export jane.doe@example.com bad.opml", 10604, "bad.opml", "jane.doe@example.com", false], + ["arsse.php export jane.doe@example.com --flat", 0, "php://output", "jane.doe@example.com", true], + ["arsse.php export jane.doe@example.com - --flat", 0, "php://output", "jane.doe@example.com", true], + ["arsse.php export --flat jane.doe@example.com good.opml", 0, "good.opml", "jane.doe@example.com", true], + ["arsse.php export jane.doe@example.com bad.opml --flat", 10604, "bad.opml", "jane.doe@example.com", true], + ]; + } } From ba32ad2f1724cfd766df9579ce668063e57fa67d Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 2 Apr 2019 09:32:31 -0400 Subject: [PATCH 077/142] Add context options for multiple tags, labels, etc --- lib/Context/ExclusionContext.php | 68 +++++++++++++++++++++++++++++--- tests/cases/Misc/TestContext.php | 25 ++++++++++-- 2 files changed, 84 insertions(+), 9 deletions(-) diff --git a/lib/Context/ExclusionContext.php b/lib/Context/ExclusionContext.php index 1f91994..63cc97d 100644 --- a/lib/Context/ExclusionContext.php +++ b/lib/Context/ExclusionContext.php @@ -11,16 +11,23 @@ use JKingWeb\Arsse\Misc\Date; class ExclusionContext { public $folder; + public $folders; public $folderShallow; + public $foldersShallow; public $tag; + public $tags; public $tagName; + public $tagNames; public $subscription; + public $subscriptions; public $edition; - public $article; public $editions; + public $article; public $articles; public $label; + public $labels; public $labelName; + public $labelNames; public $annotationTerms; public $searchTerms; public $titleTerms; @@ -70,16 +77,18 @@ class ExclusionContext { } } - protected function cleanIdArray(array $spec): array { + protected function cleanIdArray(array $spec, bool $allowZero = false): array { $spec = array_values($spec); for ($a = 0; $a < sizeof($spec); $a++) { - if (ValueInfo::id($spec[$a])) { + if (ValueInfo::id($spec[$a], $allowZero)) { $spec[$a] = (int) $spec[$a]; } else { - $spec[$a] = 0; + $spec[$a] = null; } } - return array_values(array_unique(array_filter($spec))); + return array_values(array_unique(array_filter($spec, function ($v) { + return !is_null($v); + }))); } protected function cleanStringArray(array $spec): array { @@ -99,22 +108,57 @@ class ExclusionContext { return $this->act(__FUNCTION__, func_num_args(), $spec); } + public function folders(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanIdArray($spec, true); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + public function folderShallow(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } + public function foldersShallow(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanIdArray($spec, true); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + public function tag(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } + public function tags(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanIdArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + public function tagName(string $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } + public function tagNames(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanStringArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + public function subscription(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } + public function subscriptions(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanIdArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + public function edition(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } @@ -141,10 +185,24 @@ class ExclusionContext { return $this->act(__FUNCTION__, func_num_args(), $spec); } + public function labels(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanIdArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + public function labelName(string $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } + public function labelNames(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanStringArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + public function annotationTerms(array $spec = null) { if (isset($spec)) { $spec = $this->cleanStringArray($spec); diff --git a/tests/cases/Misc/TestContext.php b/tests/cases/Misc/TestContext.php index e85d58e..f32f11e 100644 --- a/tests/cases/Misc/TestContext.php +++ b/tests/cases/Misc/TestContext.php @@ -29,10 +29,15 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { 'limit' => 10, 'offset' => 5, 'folder' => 42, + 'folders' => [12,22], 'folderShallow' => 42, + 'foldersShallow' => [0,1], 'tag' => 44, + 'tags' => [44, 2112], 'tagName' => "XLIV", + 'tagNames' => ["XLIV", "MMCXII"], 'subscription' => 2112, + 'subscriptions' => [44, 2112], 'article' => 255, 'edition' => 65535, 'latestArticle' => 47, @@ -48,7 +53,9 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { 'editions' => [1,2], 'articles' => [1,2], 'label' => 2112, + 'labels' => [2112, 1984], 'labelName' => "Rush", + 'labelNames' => ["Rush", "Orwell"], 'labelled' => true, 'annotated' => true, 'searchTerms' => ["foo", "bar"], @@ -79,9 +86,19 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { } public function testCleanIdArrayValues() { - $methods = ["articles", "editions"]; - $in = [1, "2", 3.5, 3.0, "ook", 0, -20, true, false, null, new \DateTime(), -1.0]; - $out = [1,2, 3]; + $methods = ["articles", "editions", "tags", "labels", "subscriptions"]; + $in = [1, "2", 3.5, 4.0, 4, "ook", 0, -20, true, false, null, new \DateTime(), -1.0]; + $out = [1, 2, 4]; + $c = new Context; + foreach ($methods as $method) { + $this->assertSame($out, $c->$method($in)->$method, "Context method $method did not return the expected results"); + } + } + + public function testCleanFolderIdArrayValues() { + $methods = ["folders", "foldersShallow"]; + $in = [1, "2", 3.5, 4.0, 4, "ook", 0, -20, true, false, null, new \DateTime(), -1.0]; + $out = [1, 2, 4, 0]; $c = new Context; foreach ($methods as $method) { $this->assertSame($out, $c->$method($in)->$method, "Context method $method did not return the expected results"); @@ -89,7 +106,7 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { } public function testCleanStringArrayValues() { - $methods = ["searchTerms", "annotationTerms", "titleTerms", "authorTerms"]; + $methods = ["searchTerms", "annotationTerms", "titleTerms", "authorTerms", "tagNames", "labelNames"]; $now = new \DateTime; $in = [1, 3.0, "ook", 0, true, false, null, $now, ""]; $out = ["1", "3", "ook", "0", valueInfo::normalize($now, ValueInfo::T_STRING)]; From ef1b761f9583373a1e1797c66902300206fdee42 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 2 Apr 2019 18:24:20 -0400 Subject: [PATCH 078/142] Implement most multiple-item context options Selecting multiple folder trees will require further effort --- lib/Database.php | 102 +++++++++++++++++-------- tests/cases/Database/SeriesArticle.php | 6 ++ 2 files changed, 78 insertions(+), 30 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 2486907..49f32e8 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1323,7 +1323,9 @@ class Database { "markedSince" => ["marked_date", ">=", "datetime", "notMarkedSince"], "notMarkedSince" => ["marked_date", "<=", "datetime", "markedSince"], "folderShallow" => ["folder", "=", "int", ""], + "foldersShallow" => ["folder", "in", "int", ""], "subscription" => ["subscription", "=", "int", ""], + "subscriptions" => ["subscription", "in", "int", ""], "unread" => ["unread", "=", "bool", ""], "starred" => ["starred", "=", "bool", ""], ]; @@ -1374,6 +1376,76 @@ class Database { $q->setWhereNot("{$colDefs[$col]} $op ?", $type, $context->not->$m); } } + // handle labels and tags + $options = [ + 'label' => [ + 'match_col' => "arsse_articles.id", + 'cte_name' => "labelled", + 'cte_cols' => ["article", "label_id", "label_name"], + 'cte_body' => "SELECT m.article, l.id, l.name from arsse_label_members as m join arsse_labels as l on l.id = m.label where l.owner = ? and m.assigned = 1", + 'cte_types' => ["str"], + 'cte_values' => [$user], + 'options' => [ + 'label' => ['use_name' => false, 'multi' => false], + 'labels' => ['use_name' => false, 'multi' => true], + 'labelName' => ['use_name' => true, 'multi' => false], + 'labelNames' => ['use_name' => true, 'multi' => true], + ], + ], + 'tag' => [ + 'match_col' => "arsse_subscriptions.id", + 'cte_name' => "tagged", + 'cte_cols' => ["subscription", "tag_id", "tag_name"], + 'cte_body' => "SELECT m.subscription, t.id, t.name from arsse_tag_members as m join arsse_tags as t on t.id = m.tag where t.owner = ? and m.assigned = 1", + 'cte_types' => ["str"], + 'cte_values' => [$user], + 'options' => [ + 'tag' => ['use_name' => false, 'multi' => false], + 'tags' => ['use_name' => false, 'multi' => true], + 'tagName' => ['use_name' => true, 'multi' => false], + 'tagNames' => ['use_name' => true, 'multi' => true], + ], + ], + ]; + foreach ($options as $opt) { + $seen = false; + $match = $opt['match_col']; + $table = $opt['cte_name']; + foreach ($opt['options'] as $m => $props) { + $named = $props['use_name']; + $multi = $props['multi']; + $selection = $opt['cte_cols'][0]; + $col = $opt['cte_cols'][$named ? 2 : 1]; + if ($context->$m()) { + $seen = true; + if ($multi) { + list($test, $types, $values) = $this->generateIn($context->$m, $named ? "str" : "int"); + $test = "in ($test)"; + } else { + $test = "= ?"; + $types = $named ? "str" : "int"; + $values = $context->$m; + } + $q->setWhere("$match in (select $selection from $table where $col $test)", $types, $values); + } + if ($context->not->$m()) { + $seen = true; + if ($multi) { + list($test, $types, $values) = $this->generateIn($context->not->$m, $named ? "str" : "int"); + $test = "in ($test)"; + } else { + $test = "= ?"; + $types = $named ? "str" : "int"; + $values = $context->not->$m; + } + $q->setWhereNot("$match in (select $selection from $table where $col $test)", $types, $values); + } + } + if ($seen) { + $spec = $opt['cte_name']."(".implode(",",$opt['cte_cols']).")"; + $q->setCTE($spec, $opt['cte_body'], $opt['cte_types'], $opt['cte_values']); + } + } // handle complex context options if ($context->annotated()) { $comp = ($context->annotated) ? "<>" : "="; @@ -1384,36 +1456,6 @@ class Database { $op = $context->labelled ? ">" : "="; $q->setWhere("coalesce(label_stats.assigned,0) $op 0"); } - if ($context->label() || $context->not->label() || $context->labelName() || $context->not->labelName()) { - $q->setCTE("labelled(article,label_id,label_name)","SELECT m.article, l.id, l.name from arsse_label_members as m join arsse_labels as l on l.id = m.label where l.owner = ? and m.assigned = 1", "str", $user); - if ($context->label()) { - $q->setWhere("arsse_articles.id in (select article from labelled where label_id = ?)", "int", $context->label); - } - if ($context->not->label()) { - $q->setWhereNot("arsse_articles.id in (select article from labelled where label_id = ?)", "int", $context->not->label); - } - if ($context->labelName()) { - $q->setWhere("arsse_articles.id in (select article from labelled where label_name = ?)", "str", $context->labelName); - } - if ($context->not->labelName()) { - $q->setWhereNot("arsse_articles.id in (select article from labelled where label_name = ?)", "str", $context->not->labelName); - } - } - if ($context->tag() || $context->not->tag() || $context->tagName() || $context->not->tagName()) { - $q->setCTE("tagged(id,name,subscription)","SELECT arsse_tags.id, arsse_tags.name, arsse_tag_members.subscription FROM arsse_tag_members join arsse_tags on arsse_tags.id = arsse_tag_members.tag WHERE arsse_tags.owner = ? and assigned = 1", "str", $user); - if ($context->tag()) { - $q->setWhere("arsse_subscriptions.id in (select subscription from tagged where id = ?)", "int", $context->tag); - } - if ($context->not->tag()) { - $q->setWhereNot("arsse_subscriptions.id in (select subscription from tagged where id = ?)", "int", $context->not->tag); - } - if ($context->tagName()) { - $q->setWhere("arsse_subscriptions.id in (select subscription from tagged where name = ?)", "str", $context->tagName); - } - if ($context->not->tagName()) { - $q->setWhereNot("arsse_subscriptions.id in (select subscription from tagged where name = ?)", "str", $context->not->tagName); - } - } if ($context->folder()) { // add a common table expression to list the folder and its children so that we select from the entire subtree $q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on parent = folder", "int", $context->folder); diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 5340fcc..51d9da5 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -427,7 +427,9 @@ trait SeriesArticle { 'Leaf folder' => [(new Context)->folder(6), [7,8]], 'Root folder only' => [(new Context)->folderShallow(0), [1,2,3,4]], 'Shallow folder' => [(new Context)->folderShallow(1), [5,6]], + 'Multiple shallow folders' => [(new Context)->foldersShallow([1,6]), [5,6,7,8]], 'Subscription' => [(new Context)->subscription(5), [19,20]], + 'Multiple subscriptions' => [(new Context)->subscriptions([4,5]), [7,8,19,20]], 'Unread' => [(new Context)->subscription(5)->unread(true), [20]], 'Read' => [(new Context)->subscription(5)->unread(false), [19]], 'Starred' => [(new Context)->starred(true), [1,20]], @@ -458,8 +460,10 @@ trait SeriesArticle { 'Reversed paged results' => [(new Context)->limit(2)->latestEdition(7)->reverse(true), [7,6]], 'With label ID 1' => [(new Context)->label(1), [1,19]], 'With label ID 2' => [(new Context)->label(2), [1,5,20]], + 'With label ID 1 or 2' => [(new Context)->labels([1,2]), [1,5,19,20]], 'With label "Interesting"' => [(new Context)->labelName("Interesting"), [1,19]], 'With label "Fascinating"' => [(new Context)->labelName("Fascinating"), [1,5,20]], + 'With label "Interesting" or "Fascinating"' => [(new Context)->labelNames(["Interesting","Fascinating"]), [1,5,19,20]], 'Article ID 20' => [(new Context)->article(20), [20]], 'Edition ID 1001' => [(new Context)->edition(1001), [20]], 'Multiple articles' => [(new Context)->articles([1,20,50]), [1,20]], @@ -494,8 +498,10 @@ trait SeriesArticle { 'Search 501 terms' => [(new Context)->searchTerms(array_merge(range(1,500),[str_repeat("a", 1000)])), []], 'With tag ID 1' => [(new Context)->tag(1), [5,6,7,8]], 'With tag ID 5' => [(new Context)->tag(5), [7,8,19,20]], + 'With tag ID 1 or 5' => [(new Context)->tags([1,5]), [5,6,7,8,19,20]], 'With tag "Technology"' => [(new Context)->tagName("Technology"), [5,6,7,8]], 'With tag "Politics"' => [(new Context)->tagName("Politics"), [7,8,19,20]], + 'With tag "Technology" or "Politics"' => [(new Context)->tagNames(["Technology","Politics"]), [5,6,7,8,19,20]], 'Excluding tag ID 1' => [(new Context)->not->tag(1), [1,2,3,4,19,20]], 'Excluding tag ID 5' => [(new Context)->not->tag(5), [1,2,3,4,5,6]], 'Excluding tag "Technology"' => [(new Context)->not->tagName("Technology"), [1,2,3,4,19,20]], From 98f6fca7e3d2ef88b8246e93514297b2d7c41a60 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 2 Apr 2019 18:37:46 -0400 Subject: [PATCH 079/142] Enforce minimum array size (for now) --- lib/Database.php | 3 +++ tests/cases/Database/SeriesArticle.php | 27 ++++++++++++-------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 49f32e8..404f451 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1418,6 +1418,9 @@ class Database { $col = $opt['cte_cols'][$named ? 2 : 1]; if ($context->$m()) { $seen = true; + if (!$context->$m) { + throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element + } if ($multi) { list($test, $types, $values) = $this->generateIn($context->$m, $named ? "str" : "int"); $test = "in ($test)"; diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 51d9da5..694aec5 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -789,11 +789,6 @@ trait SeriesArticle { $this->compareExpectations($state); } - public function testMarkTooFewMultipleArticles() { - $this->assertException("tooShort", "Db", "ExceptionInput"); - Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->articles([])); - } - public function testMarkTooManyMultipleArticles() { $this->assertSame(7, Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->articles(range(1, Database::LIMIT_SET_SIZE * 3)))); } @@ -860,11 +855,6 @@ trait SeriesArticle { $this->compareExpectations($state); } - public function testMarkTooFewMultipleEditions() { - $this->assertException("tooShort", "Db", "ExceptionInput"); - Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->editions([])); - } - public function testMarkTooManyMultipleEditions() { $this->assertSame(7, Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->editions(range(1, 51)))); } @@ -1036,13 +1026,20 @@ trait SeriesArticle { Arsse::$db->articleCategoriesGet($this->user, 19); } - public function testSearchTooFewTerms() { + /** @dataProvider provideArrayContextOptions */ + public function testUseTooFewValuesInArrayContext(string $option) { $this->assertException("tooShort", "Db", "ExceptionInput"); - Arsse::$db->articleList($this->user, (new Context)->searchTerms([])); + Arsse::$db->articleList($this->user, (new Context)->annotationTerms([])); } - public function testSearchTooFewTermsInNote() { - $this->assertException("tooShort", "Db", "ExceptionInput"); - Arsse::$db->articleList($this->user, (new Context)->annotationTerms([])); + public function provideArrayContextOptions() { + foreach([ + "articles", "editions", + "subscriptions", "foldersShallow", //"folders", + "tags", "tagNames", "labels", "labelNames", + "searchTerms", "authorTerms", "annotationTerms", + ] as $method) { + yield [$method]; + } } } From cce1089e10740bbf6a7ee38fc9227cfcd09f2aaf Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 2 Apr 2019 19:58:35 -0400 Subject: [PATCH 080/142] Handle edge case with folder 0 Folder 0 (the root folder) is a valid, though nonsensical selection: using it as a positive option is the same as not using the option at all, and using it as a negative option necessarily yields an empty set. However, it can in some contexts be validly specified, and so it should be handled consistently. It had not been previously, but is now. --- lib/Context/ExclusionContext.php | 1 + lib/Database.php | 4 ++-- tests/cases/Database/SeriesArticle.php | 2 ++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/Context/ExclusionContext.php b/lib/Context/ExclusionContext.php index 63cc97d..7cf45cb 100644 --- a/lib/Context/ExclusionContext.php +++ b/lib/Context/ExclusionContext.php @@ -49,6 +49,7 @@ class ExclusionContext { } public function __clone() { + // if the context was cloned because its parent was cloned, change the parent to the clone if ($this->parent) { $t = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT, 2)[1]; if (($t['object'] ?? null) instanceof self && $t['function'] === "__clone") { diff --git a/lib/Database.php b/lib/Database.php index 404f451..1173ae6 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1461,13 +1461,13 @@ class Database { } if ($context->folder()) { // add a common table expression to list the folder and its children so that we select from the entire subtree - $q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on parent = folder", "int", $context->folder); + $q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on coalesce(parent,0) = folder", "int", $context->folder); // limit subscriptions to the listed folders $q->setWhere("coalesce(arsse_subscriptions.folder,0) in (select folder from folders)"); } if ($context->not->folder()) { // add a common table expression to list the folder and its children so that we exclude from the entire subtree - $q->setCTE("folders_excluded(folder)", "SELECT ? union select id from arsse_folders join folders_excluded on parent = folder", "int", $context->not->folder); + $q->setCTE("folders_excluded(folder)", "SELECT ? union select id from arsse_folders join folders_excluded on coalesce(parent,0) = folder", "int", $context->not->folder); // excluded any subscriptions in the listed folders $q->setWhereNot("coalesce(arsse_subscriptions.folder,0) in (select folder from folders_excluded)"); } diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 694aec5..31dcf96 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -424,6 +424,7 @@ trait SeriesArticle { return [ 'Blank context' => [new Context, [1,2,3,4,5,6,7,8,19,20]], 'Folder tree' => [(new Context)->folder(1), [5,6,7,8]], + 'Entire folder tree' => [(new Context)->folder(0), [1,2,3,4,5,6,7,8,19,20]], 'Leaf folder' => [(new Context)->folder(6), [7,8]], 'Root folder only' => [(new Context)->folderShallow(0), [1,2,3,4]], 'Shallow folder' => [(new Context)->folderShallow(1), [5,6]], @@ -506,6 +507,7 @@ trait SeriesArticle { 'Excluding tag ID 5' => [(new Context)->not->tag(5), [1,2,3,4,5,6]], 'Excluding tag "Technology"' => [(new Context)->not->tagName("Technology"), [1,2,3,4,19,20]], 'Excluding tag "Politics"' => [(new Context)->not->tagName("Politics"), [1,2,3,4,5,6]], + 'Excluding entire folder tree' => [(new Context)->not->folder(0), []], ]; } From 74fc39fca034a152d301baea9ea9f0b64a53d8ec Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 2 Apr 2019 22:44:09 -0400 Subject: [PATCH 081/142] Implement multi-folder context option --- lib/Database.php | 14 ++++++++++++++ tests/cases/Database/SeriesArticle.php | 6 +++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/Database.php b/lib/Database.php index 1173ae6..459f467 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1465,12 +1465,26 @@ class Database { // limit subscriptions to the listed folders $q->setWhere("coalesce(arsse_subscriptions.folder,0) in (select folder from folders)"); } + if ($context->folders()) { + list($inClause, $inTypes, $inValues) = $this->generateIn($context->folders, "int"); + // add a common table expression to list the folders and their children so that we select from the entire subtree + $q->setCTE("folders_multi(folder)", "SELECT id as folder from (select id from (select 0 as id union select id from arsse_folders where owner = ?) as f where id in ($inClause)) as folders_multi union select id from arsse_folders join folders_multi on coalesce(parent,0) = folder", ["str", $inTypes], [$user, $inValues]); + // limit subscriptions to the listed folders + $q->setWhere("coalesce(arsse_subscriptions.folder,0) in (select folder from folders_multi)"); + } if ($context->not->folder()) { // add a common table expression to list the folder and its children so that we exclude from the entire subtree $q->setCTE("folders_excluded(folder)", "SELECT ? union select id from arsse_folders join folders_excluded on coalesce(parent,0) = folder", "int", $context->not->folder); // excluded any subscriptions in the listed folders $q->setWhereNot("coalesce(arsse_subscriptions.folder,0) in (select folder from folders_excluded)"); } + if ($context->not->folders()) { + list($inClause, $inTypes, $inValues) = $this->generateIn($context->not->folders, "int"); + // add a common table expression to list the folders and their children so that we select from the entire subtree + $q->setCTE("folders_multi_excluded(folder)", "SELECT id as folder from (select id from (select 0 as id union select id from arsse_folders where owner = ?) as f where id in ($inClause)) as folders_multi_excluded union select id from arsse_folders join folders_multi_excluded on coalesce(parent,0) = folder", ["str", $inTypes], [$user, $inValues]); + // limit subscriptions to the listed folders + $q->setWhereNot("coalesce(arsse_subscriptions.folder,0) in (select folder from folders_multi_excluded)"); + } // handle text-matching context options $options = [ "titleTerms" => ["arsse_articles.title"], diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 31dcf96..17b0ece 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -426,8 +426,10 @@ trait SeriesArticle { 'Folder tree' => [(new Context)->folder(1), [5,6,7,8]], 'Entire folder tree' => [(new Context)->folder(0), [1,2,3,4,5,6,7,8,19,20]], 'Leaf folder' => [(new Context)->folder(6), [7,8]], - 'Root folder only' => [(new Context)->folderShallow(0), [1,2,3,4]], + 'Multiple folder trees' => [(new Context)->folders([1,5]), [5,6,7,8,19,20]], + 'Multiple folder trees including root' => [(new Context)->folders([0,1,5]), [1,2,3,4,5,6,7,8,19,20]], 'Shallow folder' => [(new Context)->folderShallow(1), [5,6]], + 'Root folder only' => [(new Context)->folderShallow(0), [1,2,3,4]], 'Multiple shallow folders' => [(new Context)->foldersShallow([1,6]), [5,6,7,8]], 'Subscription' => [(new Context)->subscription(5), [19,20]], 'Multiple subscriptions' => [(new Context)->subscriptions([4,5]), [7,8,19,20]], @@ -508,6 +510,8 @@ trait SeriesArticle { 'Excluding tag "Technology"' => [(new Context)->not->tagName("Technology"), [1,2,3,4,19,20]], 'Excluding tag "Politics"' => [(new Context)->not->tagName("Politics"), [1,2,3,4,5,6]], 'Excluding entire folder tree' => [(new Context)->not->folder(0), []], + 'Excluding multiple folder trees' => [(new Context)->not->folders([1,5]), [1,2,3,4]], + 'Excluding multiple folder trees including root' => [(new Context)->not->folders([0,1,5]), []], ]; } From 4b133bddd640dcafd80c2fff5c1243119ab38842 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 3 Apr 2019 15:02:59 -0400 Subject: [PATCH 082/142] Prototype arbitrary result ordering --- lib/Database.php | 60 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 459f467..341b267 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1226,7 +1226,7 @@ class Database { * @param Context $context The search context * @param array $cols The columns to request in the result set */ - protected function articleQuery(string $user, Context $context, array $cols = ["id"]): Query { + protected function articleQuery(string $user, Context $context, array $cols = ["id"], array $sort = []): Query { // validate input if ($context->subscription()) { $this->subscriptionValidateId($user, $context->subscription); @@ -1275,23 +1275,55 @@ class Database { 'media_type' => "arsse_enclosures.type", ]; if (!$cols) { - // if no columns are specified return a count - $columns = "count(distinct arsse_articles.id) as count"; + // if no columns are specified return a count; don't borther with sorting + $outColumns = "count(distinct arsse_articles.id) as count"; + $sortColumns = []; } else { - $columns = []; + // normalize requested output and sorting columns + $norm = function($v) { + return trim(strtolower(ValueInfo::normalize($v, ValueInfo::T_STRING))); + }; + $cols = array_map($norm, $cols); + $sort = array_map($norm, $sort); + // make an output column list + $outColumns = []; foreach ($cols as $col) { - $col = trim(strtolower($col)); if (!isset($colDefs[$col])) { continue; } - $columns[] = $colDefs[$col]." as ".$col; + $outColumns[] = $colDefs[$col]." as ".$col; + } + $outColumns = implode(",", $outColumns); + // make an ORDER BY column list + $sortColumns = []; + foreach ($sort as $spec) { + $col = explode(" ", $spec, 1); + $order = $col[1] ?? ""; + $col = $col[0]; + if ($order === "desc") { + $order = " desc"; + } elseif ($order === "asc" || $order === "") { + $order = ""; + } else { + // column direction spec is bogus + continue; + } + if (!isset($colDefs[$col])) { + // column name spec is bogus + continue; + } elseif (in_array($col, $cols)) { + // if the sort column is also an output column, use it as-is + $sortColumns[] = $col.$order; + } else { + // otherwise if the column name is valid, use its expression + $sortColumns[] = $colDefs[$col].$order; + } } - $columns = implode(",", $columns); } // define the basic query, to which we add lots of stuff where necessary $q = new Query( "SELECT - $columns + $outColumns from arsse_articles join arsse_subscriptions on arsse_subscriptions.feed = arsse_articles.feed and arsse_subscriptions.owner = ? join arsse_feeds on arsse_subscriptions.feed = arsse_feeds.id @@ -1307,6 +1339,10 @@ class Database { [$user, $user] ); $q->setLimit($context->limit, $context->offset); + // apply the ORDER BY definition computed above + array_walk($sortColumns, function($v, $k, Query $q) { + $q->setOrder($v); + }, $q); // handle the simple context options $options = [ // each context array consists of a column identifier (see $colDefs above), a comparison operator, a data type, and an option to pair with for BETWEEN evaluation @@ -1492,20 +1528,20 @@ class Database { "authorTerms" => ["arsse_articles.author"], "annotationTerms" => ["arsse_marks.note"], ]; - foreach ($options as $m => $cols) { + foreach ($options as $m => $columns) { if (!$context->$m()) { continue; } elseif (!$context->$m) { throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element } - $q->setWhere(...$this->generateSearch($context->$m, $cols)); + $q->setWhere(...$this->generateSearch($context->$m, $columns)); } // further handle exclusionary text-matching context options - foreach ($options as $m => $cols) { + foreach ($options as $m => $columns) { if (!$context->not->$m() || !$context->not->$m) { continue; } - $q->setWhereNot(...$this->generateSearch($context->not->$m, $cols, true)); + $q->setWhereNot(...$this->generateSearch($context->not->$m, $columns, true)); } // return the query return $q; From 156ce2d09970860dde25d200ce577d3d4e06576e Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 4 Apr 2019 11:20:40 -0400 Subject: [PATCH 083/142] Fix Unix Robo script --- robo | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/robo b/robo index 0b3be08..f525941 100755 --- a/robo +++ b/robo @@ -5,7 +5,7 @@ shift ulimit -n 2048 if [ "$1" = "clean" ]; then - "$base/vendor/bin/robo" "$roboCommand" $* + "$base/vendor/bin/robo" "$roboCommand" "$@" else - "$base/vendor/bin/robo" "$roboCommand" -- $* + "$base/vendor/bin/robo" "$roboCommand" -- "$@" fi From f72c85c9f64f0015e3b5e37e4c2303ac050b4c58 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 4 Apr 2019 11:22:50 -0400 Subject: [PATCH 084/142] Hopefully working but maybe broken custom sorting --- lib/Context/Context.php | 5 - lib/Database.php | 130 +++++++++++++------------ lib/REST/NextCloudNews/V1_2.php | 10 +- lib/REST/TinyTinyRSS/API.php | 13 +-- tests/cases/Database/SeriesArticle.php | 1 - 5 files changed, 79 insertions(+), 80 deletions(-) diff --git a/lib/Context/Context.php b/lib/Context/Context.php index 858409f..fb1236a 100644 --- a/lib/Context/Context.php +++ b/lib/Context/Context.php @@ -9,7 +9,6 @@ namespace JKingWeb\Arsse\Context; class Context extends ExclusionContext { /** @var ExclusionContext */ public $not; - public $reverse = false; public $limit = 0; public $offset = 0; public $unread; @@ -31,10 +30,6 @@ class Context extends ExclusionContext { unset($this->not); } - public function reverse(bool $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - public function limit(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } diff --git a/lib/Database.php b/lib/Database.php index 341b267..ff74f65 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1218,6 +1218,37 @@ class Database { )->run($feedID, $vId, $vHashUT, $vHashUC, $vHashTC); } + /** Returns an associative array of result column names and their SQL computations for article queries + * + * This is used for whitelisting and defining both output column and order-by columns, as well as for resolution of some context options + */ + protected function articleColumns(): array { + $greatest = $this->db->sqlToken("greatest"); + return [ + 'id' => "arsse_articles.id", + 'edition' => "latest_editions.edition", + 'url' => "arsse_articles.url", + 'title' => "arsse_articles.title", + 'author' => "arsse_articles.author", + 'content' => "arsse_articles.content", + 'guid' => "arsse_articles.guid", + 'fingerprint' => "arsse_articles.url_title_hash || ':' || arsse_articles.url_content_hash || ':' || arsse_articles.title_content_hash", + 'folder' => "coalesce(arsse_subscriptions.folder,0)", + 'subscription' => "arsse_subscriptions.id", + 'feed' => "arsse_subscriptions.feed", + 'starred' => "coalesce(arsse_marks.starred,0)", + 'unread' => "abs(coalesce(arsse_marks.read,0) - 1)", + 'note' => "coalesce(arsse_marks.note,'')", + 'published_date' => "arsse_articles.published", + 'edited_date' => "arsse_articles.edited", + 'modified_date' => "arsse_articles.modified", + 'marked_date' => "$greatest(arsse_articles.modified, coalesce(arsse_marks.modified, '0001-01-01 00:00:00'), coalesce(label_stats.modified, '0001-01-01 00:00:00'))", + 'subscription_title' => "coalesce(arsse_subscriptions.title, arsse_feeds.title)", + 'media_url' => "arsse_enclosures.url", + 'media_type' => "arsse_enclosures.type", + ]; + } + /** Computes an SQL query to find and retrieve data about articles in the database * * If an empty column list is supplied, a count of articles matching the context is queried instead @@ -1226,14 +1257,14 @@ class Database { * @param Context $context The search context * @param array $cols The columns to request in the result set */ - protected function articleQuery(string $user, Context $context, array $cols = ["id"], array $sort = []): Query { + protected function articleQuery(string $user, Context $context, array $cols = ["id"]): Query { // validate input if ($context->subscription()) { $this->subscriptionValidateId($user, $context->subscription); } if ($context->folder()) { $this->folderValidateId($user, $context->folder); - } + } if ($context->folderShallow()) { $this->folderValidateId($user, $context->folderShallow); } @@ -1250,41 +1281,16 @@ class Database { $this->labelValidateId($user, $context->labelName, true); } // prepare the output column list; the column definitions are also used later - $greatest = $this->db->sqlToken("greatest"); - $colDefs = [ - 'id' => "arsse_articles.id", - 'edition' => "latest_editions.edition", - 'url' => "arsse_articles.url", - 'title' => "arsse_articles.title", - 'author' => "arsse_articles.author", - 'content' => "arsse_articles.content", - 'guid' => "arsse_articles.guid", - 'fingerprint' => "arsse_articles.url_title_hash || ':' || arsse_articles.url_content_hash || ':' || arsse_articles.title_content_hash", - 'folder' => "coalesce(arsse_subscriptions.folder,0)", - 'subscription' => "arsse_subscriptions.id", - 'feed' => "arsse_subscriptions.feed", - 'starred' => "coalesce(arsse_marks.starred,0)", - 'unread' => "abs(coalesce(arsse_marks.read,0) - 1)", - 'note' => "coalesce(arsse_marks.note,'')", - 'published_date' => "arsse_articles.published", - 'edited_date' => "arsse_articles.edited", - 'modified_date' => "arsse_articles.modified", - 'marked_date' => "$greatest(arsse_articles.modified, coalesce(arsse_marks.modified, '0001-01-01 00:00:00'), coalesce(label_stats.modified, '0001-01-01 00:00:00'))", - 'subscription_title' => "coalesce(arsse_subscriptions.title, arsse_feeds.title)", - 'media_url' => "arsse_enclosures.url", - 'media_type' => "arsse_enclosures.type", - ]; + $colDefs = $this->articleColumns(); if (!$cols) { // if no columns are specified return a count; don't borther with sorting $outColumns = "count(distinct arsse_articles.id) as count"; - $sortColumns = []; } else { // normalize requested output and sorting columns $norm = function($v) { return trim(strtolower(ValueInfo::normalize($v, ValueInfo::T_STRING))); }; $cols = array_map($norm, $cols); - $sort = array_map($norm, $sort); // make an output column list $outColumns = []; foreach ($cols as $col) { @@ -1294,31 +1300,6 @@ class Database { $outColumns[] = $colDefs[$col]." as ".$col; } $outColumns = implode(",", $outColumns); - // make an ORDER BY column list - $sortColumns = []; - foreach ($sort as $spec) { - $col = explode(" ", $spec, 1); - $order = $col[1] ?? ""; - $col = $col[0]; - if ($order === "desc") { - $order = " desc"; - } elseif ($order === "asc" || $order === "") { - $order = ""; - } else { - // column direction spec is bogus - continue; - } - if (!isset($colDefs[$col])) { - // column name spec is bogus - continue; - } elseif (in_array($col, $cols)) { - // if the sort column is also an output column, use it as-is - $sortColumns[] = $col.$order; - } else { - // otherwise if the column name is valid, use its expression - $sortColumns[] = $colDefs[$col].$order; - } - } } // define the basic query, to which we add lots of stuff where necessary $q = new Query( @@ -1339,10 +1320,6 @@ class Database { [$user, $user] ); $q->setLimit($context->limit, $context->offset); - // apply the ORDER BY definition computed above - array_walk($sortColumns, function($v, $k, Query $q) { - $q->setOrder($v); - }, $q); // handle the simple context options $options = [ // each context array consists of a column identifier (see $colDefs above), a comparison operator, a data type, and an option to pair with for BETWEEN evaluation @@ -1553,16 +1530,47 @@ class Database { * * @param string $user The user whose articles are to be listed * @param Context $context The search context - * @param array $cols The columns to return in the result set, any of: id, edition, url, title, author, content, guid, fingerprint, folder, subscription, feed, starred, unread, note, published_date, edited_date, modified_date, marked_date, subscription_title, media_url, media_type + * @param array $fieldss The columns to return in the result set, any of: id, edition, url, title, author, content, guid, fingerprint, folder, subscription, feed, starred, unread, note, published_date, edited_date, modified_date, marked_date, subscription_title, media_url, media_type + * @param array $sort The columns to sort the result by eg. "edition desc" in decreasing order of importance */ - public function articleList(string $user, Context $context = null, array $fields = ["id"]): Db\Result { + public function articleList(string $user, Context $context = null, array $fields = ["id"], array $sort = []): Db\Result { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } + // make a base query based on context and output columns $context = $context ?? new Context; $q = $this->articleQuery($user, $context, $fields); - $q->setOrder("arsse_articles.edited".($context->reverse ? " desc" : "")); - $q->setOrder("latest_editions.edition".($context->reverse ? " desc" : "")); + // make an ORDER BY column list + $colDefs = $this->articleColumns(); + // normalize requested output and sorting columns + $norm = function($v) { + return trim(strtolower((string) $v)); + }; + $fields = array_map($norm, $fields); + $sort = array_map($norm, $sort); + foreach ($sort as $spec) { + $col = explode(" ", $spec, 1); + $order = $col[1] ?? ""; + $col = $col[0]; + if ($order === "desc") { + $order = " desc"; + } elseif ($order === "asc" || $order === "") { + $order = ""; + } else { + // column direction spec is bogus + continue; + } + if (!isset($colDefs[$col])) { + // column name spec is bogus + continue; + } elseif (in_array($col, $fields)) { + // if the sort column is also an output column, use it as-is + $q->setOrder($col.$order); + } else { + // otherwise if the column name is valid, use its expression + $q->setOrder($colDefs[$col].$order); + } + } // perform the query and return results return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); } diff --git a/lib/REST/NextCloudNews/V1_2.php b/lib/REST/NextCloudNews/V1_2.php index 7f4301c..0df5032 100644 --- a/lib/REST/NextCloudNews/V1_2.php +++ b/lib/REST/NextCloudNews/V1_2.php @@ -521,14 +521,10 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { $c->limit($data['batchSize']); } // set the order of returned items - if ($data['oldestFirst']) { - $c->reverse(false); - } else { - $c->reverse(true); - } + $reverse = !$data['oldestFirst']; // set the edition mark-off; the database uses an or-equal comparison for internal consistency, but the protocol does not, so we must adjust by one if ($data['offset'] > 0) { - if ($c->reverse) { + if ($reverse) { $c->latestEdition($data['offset'] - 1); } else { $c->oldestEdition($data['offset'] + 1); @@ -579,7 +575,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { "starred", "modified_date", "fingerprint", - ]); + ], [$reverse ? "edition desc" : "edition"]); } catch (ExceptionInput $e) { // ID of subscription or folder is not valid return new EmptyResponse(422); diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 8bf85bc..b274f20 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -23,6 +23,7 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; use Zend\Diactoros\Response\JsonResponse as Response; use Zend\Diactoros\Response\EmptyResponse; +use Robo\Task\Archive\Pack; class API extends \JKingWeb\Arsse\REST\AbstractHandler { const LEVEL = 14; // emulated API level @@ -1438,7 +1439,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // no context needed here break; case self::FEED_READ: - $c->markedSince(Date::sub("PT24H"))->unread(false); // FIXME: this selects any recently touched article which is read, not necessarily a recently read one + $c->markedSince(Date::sub("PT24H"))->unread(false); // FIXME: this selects any recently touched (read, starred, annotated) article which is read, not necessarily a recently read one break; default: // any actual feed @@ -1491,15 +1492,15 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { switch ($data['order_by']) { case "date_reverse": // sort oldest first - $c->reverse(false); + $order = ["edited_date"]; break; case "feed_dates": // sort newest first - $c->reverse(true); + $order = ["edited_date desc"]; break; default: - // in TT-RSS the default sort order is unusual for some of the special feeds; we do not implement this - $c->reverse(true); + // sort most recently marked for special feeds, newest first otherwise + $order = (!$cat && ($id == self::FEED_READ || $id == self::FEED_STARRED)) ? ["marked_date desc"] : ["edited_date desc"]; break; } // set the limit and offset @@ -1514,6 +1515,6 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $c->oldestArticle($data['since_id'] + 1); } // return results - return Arsse::$db->articleList(Arsse::$user->id, $c, $fields); + return Arsse::$db->articleList(Arsse::$user->id, $c, $fields, $order); } } diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 17b0ece..fb547c1 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -460,7 +460,6 @@ trait SeriesArticle { 'Marked or labelled between 2000 and 2015' => [(new Context)->markedSince("2000-01-01T00:00:00Z")->notMarkedSince("2015-12-31T23:59:59Z"), [1,2,3,4,5,6,7,8,20]], 'Marked or labelled in 2010' => [(new Context)->markedSince("2010-01-01T00:00:00Z")->notMarkedSince("2010-12-31T23:59:59Z"), [2,4,6,20]], 'Paged results' => [(new Context)->limit(2)->oldestEdition(4), [4,5]], - 'Reversed paged results' => [(new Context)->limit(2)->latestEdition(7)->reverse(true), [7,6]], 'With label ID 1' => [(new Context)->label(1), [1,19]], 'With label ID 2' => [(new Context)->label(2), [1,5,20]], 'With label ID 1 or 2' => [(new Context)->labels([1,2]), [1,5,19,20]], From 12f23ddc164b332c3eb491c5b1c4bc80831ba11d Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 4 Apr 2019 17:21:23 -0400 Subject: [PATCH 085/142] Updated tests for arbitrary sorting --- lib/Database.php | 2 +- tests/cases/Database/SeriesArticle.php | 24 ++++++- tests/cases/REST/NextCloudNews/TestV1_2.php | 34 ++++----- tests/cases/REST/TinyTinyRSS/TestAPI.php | 80 ++++++++++----------- 4 files changed, 81 insertions(+), 59 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index ff74f65..fa754fd 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1549,7 +1549,7 @@ class Database { $fields = array_map($norm, $fields); $sort = array_map($norm, $sort); foreach ($sort as $spec) { - $col = explode(" ", $spec, 1); + $col = explode(" ", $spec, 2); $order = $col[1] ?? ""; $col = $col[0]; if ($order === "desc") { diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index fb547c1..d47f918 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -10,6 +10,7 @@ use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Misc\Date; +use JKingWeb\Arsse\Misc\ValueInfo; use Phake; trait SeriesArticle { @@ -508,6 +509,8 @@ trait SeriesArticle { 'Excluding tag ID 5' => [(new Context)->not->tag(5), [1,2,3,4,5,6]], 'Excluding tag "Technology"' => [(new Context)->not->tagName("Technology"), [1,2,3,4,19,20]], 'Excluding tag "Politics"' => [(new Context)->not->tagName("Politics"), [1,2,3,4,5,6]], + 'Excluding tags ID 1 and 5' => [(new Context)->not->tags([1,5]), [1,2,3,4]], + 'Excluding tags "Technology" and "Politics"' => [(new Context)->not->tagNames(["Technology","Politics"]), [1,2,3,4]], 'Excluding entire folder tree' => [(new Context)->not->folder(0), []], 'Excluding multiple folder trees' => [(new Context)->not->folders([1,5]), [1,2,3,4]], 'Excluding multiple folder trees including root' => [(new Context)->not->folders([0,1,5]), []], @@ -574,6 +577,25 @@ trait SeriesArticle { $this->assertEquals($this->fields, $test); } + /** @dataProvider provideOrderedLists */ + public function testListArticlesCheckingOrder(array $sortCols, array $exp) { + $act = ValueInfo::normalize(array_column(iterator_to_array(Arsse::$db->articleList("john.doe@example.com", null, ["id"], $sortCols)), "id"), ValueInfo::T_INT | ValueInfo::M_ARRAY); + $this->assertSame($exp, $act); + } + + public function provideOrderedLists() { + return [ + [["id"], [1,2,3,4,5,6,7,8,19,20]], + [["id asc"], [1,2,3,4,5,6,7,8,19,20]], + [["id desc"], [20,19,8,7,6,5,4,3,2,1]], + [["edition"], [1,2,3,4,5,6,7,8,19,20]], + [["edition asc"], [1,2,3,4,5,6,7,8,19,20]], + [["edition desc"], [20,19,8,7,6,5,4,3,2,1]], + [["id", "edition desk"], [1,2,3,4,5,6,7,8,19,20]], + [["id", "editio"], [1,2,3,4,5,6,7,8,19,20]], + ]; + } + public function testListArticlesWithoutAuthority() { Phake::when(Arsse::$user)->authorize->thenReturn(false); $this->assertException("notAuthorized", "User", "ExceptionAuthz"); @@ -1034,7 +1056,7 @@ trait SeriesArticle { /** @dataProvider provideArrayContextOptions */ public function testUseTooFewValuesInArrayContext(string $option) { $this->assertException("tooShort", "Db", "ExceptionInput"); - Arsse::$db->articleList($this->user, (new Context)->annotationTerms([])); + Arsse::$db->articleList($this->user, (new Context)->$option([])); } public function provideArrayContextOptions() { diff --git a/tests/cases/REST/NextCloudNews/TestV1_2.php b/tests/cases/REST/NextCloudNews/TestV1_2.php index 664db4e..52291cb 100644 --- a/tests/cases/REST/NextCloudNews/TestV1_2.php +++ b/tests/cases/REST/NextCloudNews/TestV1_2.php @@ -734,11 +734,11 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { ['lastModified' => $t->getTimestamp()], ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 0], // offset=0 should not set the latestEdition context ]; - Phake::when(Arsse::$db)->articleList(Arsse::$user->id, $this->anything(), $this->anything())->thenReturn(new Result($this->v($this->articles['db']))); - Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(42), $this->anything())->thenThrow(new ExceptionInput("idMissing")); - Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(2112), $this->anything())->thenThrow(new ExceptionInput("idMissing")); - Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(-1), $this->anything())->thenThrow(new ExceptionInput("typeViolation")); - Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(-1), $this->anything())->thenThrow(new ExceptionInput("typeViolation")); + Phake::when(Arsse::$db)->articleList->thenReturn(new Result($this->v($this->articles['db']))); + Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->subscription(42), $this->anything(), ["edition desc"])->thenThrow(new ExceptionInput("idMissing")); + Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->folder(2112), $this->anything(), ["edition desc"])->thenThrow(new ExceptionInput("idMissing")); + Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->subscription(-1), $this->anything(), ["edition desc"])->thenThrow(new ExceptionInput("typeViolation")); + Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->folder(-1), $this->anything(), ["edition desc"])->thenThrow(new ExceptionInput("typeViolation")); $exp = new Response(['items' => $this->articles['rest']]); // check the contents of the response $this->assertMessage($exp, $this->req("GET", "/items")); // first instance of base context @@ -759,17 +759,17 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { $this->req("GET", "/items", json_encode($in[10])); $this->req("GET", "/items", json_encode($in[11])); // perform method verifications - Phake::verify(Arsse::$db, Phake::times(4))->articleList(Arsse::$user->id, (new Context)->reverse(true), $this->anything()); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(42), $this->anything()); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(2112), $this->anything()); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(-1), $this->anything()); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(-1), $this->anything()); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->starred(true), $this->anything()); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(false)->limit(10)->oldestEdition(6), $this->anything()); // offset is one more than specified - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->limit(5)->latestEdition(4), $this->anything()); // offset is one less than specified - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->unread(true), $this->anything()); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, $this->equalTo((new Context)->reverse(true)->markedSince($t), 2), $this->anything()); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->limit(5), $this->anything()); + Phake::verify(Arsse::$db, Phake::times(4))->articleList(Arsse::$user->id, new Context, $this->anything(), ["edition desc"]); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->subscription(42), $this->anything(), ["edition desc"]); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->folder(2112), $this->anything(), ["edition desc"]); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->subscription(-1), $this->anything(), ["edition desc"]); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->folder(-1), $this->anything(), ["edition desc"]); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->starred(true), $this->anything(), ["edition desc"]); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->limit(10)->oldestEdition(6), $this->anything(), ["edition"]); // offset is one more than specified + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->limit(5)->latestEdition(4), $this->anything(), ["edition desc"]); // offset is one less than specified + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->unread(true), $this->anything(), ["edition desc"]); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, $this->equalTo((new Context)->markedSince($t), 2), $this->anything(), ["edition desc"]); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->limit(5), $this->anything(), ["edition desc"]); } public function testMarkAFolderRead() { @@ -958,6 +958,6 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { $url = "/items?type=2"; Phake::when(Arsse::$db)->articleList->thenReturn(new Result([])); $this->req("GET", $url, json_encode($in)); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(false)->starred(true), $this->anything()); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->starred(true), $this->anything(), ["edition"]); } } diff --git a/tests/cases/REST/TinyTinyRSS/TestAPI.php b/tests/cases/REST/TinyTinyRSS/TestAPI.php index 91b370c..dfe4077 100644 --- a/tests/cases/REST/TinyTinyRSS/TestAPI.php +++ b/tests/cases/REST/TinyTinyRSS/TestAPI.php @@ -1749,19 +1749,19 @@ LONG_STRING; Phake::when(Arsse::$db)->articleList->thenReturn(new Result($this->v([['id' => 0]]))); Phake::when(Arsse::$db)->articleCount->thenReturn(0); Phake::when(Arsse::$db)->articleCount($this->anything(), (new Context)->unread(true))->thenReturn(1); - $c = (new Context)->reverse(true); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(2112), ["id"])->thenThrow(new ExceptionInput("subjectMissing")); - Phake::when(Arsse::$db)->articleList($this->anything(), $c, ["id"])->thenReturn(new Result($this->v($this->articles))); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->starred(true), ["id"])->thenReturn(new Result($this->v([['id' => 1]]))); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088), ["id"])->thenReturn(new Result($this->v([['id' => 2]]))); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true), ["id"])->thenReturn(new Result($this->v([['id' => 3]]))); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088)->unread(true), ["id"])->thenReturn(new Result($this->v([['id' => 4]]))); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->starred(true), ["id"])->thenReturn(new Result($this->v([['id' => 5]]))); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->annotated(true), ["id"])->thenReturn(new Result($this->v([['id' => 6]]))); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5), ["id"])->thenReturn(new Result($this->v([['id' => 7]]))); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->offset(2), ["id"])->thenReturn(new Result($this->v([['id' => 8]]))); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5)->offset(2), ["id"])->thenReturn(new Result($this->v([['id' => 9]]))); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->oldestArticle(48), ["id"])->thenReturn(new Result($this->v([['id' => 10]]))); + $c = (new Context); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(2112), ["id"], ["edited_date desc"])->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->articleList($this->anything(), $c, ["id"], ["edited_date desc"])->thenReturn(new Result($this->v($this->articles))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->starred(true), ["id"], ["marked_date desc"])->thenReturn(new Result($this->v([['id' => 1]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 2]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 3]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088)->unread(true), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 4]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->starred(true), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 5]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->annotated(true), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 6]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 7]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->offset(2), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 8]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5)->offset(2), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 9]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->oldestArticle(48), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 10]]))); $out1 = [ $this->respErr("INCORRECT_USAGE"), $this->respGood([]), @@ -1793,9 +1793,9 @@ LONG_STRING; $this->assertMessage($out1[$a], $this->req($in1[$a]), "Test $a failed"); } for ($a = 0; $a < sizeof($in2); $a++) { - Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(false)->markedSince(Date::sub("PT24H")), 2), ["id"])->thenReturn(new Result($this->v([['id' => 1001]]))); - Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(true)->modifiedSince(Date::sub("PT24H")), 2), ["id"])->thenReturn(new Result($this->v([['id' => 1002]]))); - Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(true)->modifiedSince(Date::sub("PT24H"))->starred(true), 2), ["id"])->thenReturn(new Result($this->v([['id' => 1003]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(false)->markedSince(Date::sub("PT24H")), 2), ["id"], ["marked_date desc"])->thenReturn(new Result($this->v([['id' => 1001]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(true)->modifiedSince(Date::sub("PT24H")), 2), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 1002]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(true)->modifiedSince(Date::sub("PT24H"))->starred(true), 2), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 1003]]))); $this->assertMessage($out2[$a], $this->req($in2[$a]), "Test $a failed"); } } @@ -1853,25 +1853,25 @@ LONG_STRING; Phake::when(Arsse::$db)->articleList->thenReturn($this->generateHeadlines(0)); Phake::when(Arsse::$db)->articleCount->thenReturn(0); Phake::when(Arsse::$db)->articleCount($this->anything(), (new Context)->unread(true))->thenReturn(1); - $c = (new Context)->limit(200)->reverse(true); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(2112), $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->starred(true), $this->anything())->thenReturn($this->generateHeadlines(1)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088), $this->anything())->thenReturn($this->generateHeadlines(2)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true), $this->anything())->thenReturn($this->generateHeadlines(3)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088)->unread(true), $this->anything())->thenReturn($this->generateHeadlines(4)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->starred(true), $this->anything())->thenReturn($this->generateHeadlines(5)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->annotated(true), $this->anything())->thenReturn($this->generateHeadlines(6)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5), $this->anything())->thenReturn($this->generateHeadlines(7)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->offset(2), $this->anything())->thenReturn($this->generateHeadlines(8)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5)->offset(2), $this->anything())->thenReturn($this->generateHeadlines(9)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->oldestArticle(48), $this->anything())->thenReturn($this->generateHeadlines(10)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c), $this->anything())->thenReturn($this->generateHeadlines(11)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->labelled(true), $this->anything())->thenReturn($this->generateHeadlines(12)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folderShallow(0), $this->anything())->thenReturn($this->generateHeadlines(13)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folderShallow(42), $this->anything())->thenReturn($this->generateHeadlines(14)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folder(42), $this->anything())->thenReturn($this->generateHeadlines(15)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->reverse(false), $this->anything())->thenReturn($this->generateHeadlines(16)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->searchTerms(["interesting"]), $this->anything())->thenReturn($this->generateHeadlines(17)); + $c = (new Context)->limit(200); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(2112), $this->anything(), ["edited_date desc"])->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->starred(true), $this->anything(), ["marked_date desc"])->thenReturn($this->generateHeadlines(1)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(2)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(3)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088)->unread(true), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(4)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->starred(true), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(5)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->annotated(true), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(6)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(7)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->offset(2), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(8)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5)->offset(2), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(9)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->oldestArticle(48), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(10)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(11)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->labelled(true), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(12)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folderShallow(0), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(13)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folderShallow(42), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(14)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folder(42), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(15)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c), $this->anything(), ["edited_date"])->thenReturn($this->generateHeadlines(16)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->searchTerms(["interesting"]), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(17)); $out2 = [ $this->respErr("INCORRECT_USAGE"), $this->outputHeadlines(11), @@ -1909,9 +1909,9 @@ LONG_STRING; $this->assertMessage($out2[$a], $this->req($in2[$a]), "Test $a failed"); } for ($a = 0; $a < sizeof($in3); $a++) { - Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(false)->markedSince(Date::sub("PT24H")), 2), $this->anything())->thenReturn($this->generateHeadlines(1001)); - Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(true)->modifiedSince(Date::sub("PT24H")), 2), $this->anything())->thenReturn($this->generateHeadlines(1002)); - Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(true)->modifiedSince(Date::sub("PT24H"))->starred(true), 2), $this->anything())->thenReturn($this->generateHeadlines(1003)); + Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(false)->markedSince(Date::sub("PT24H")), 2), $this->anything(), ["marked_date desc"])->thenReturn($this->generateHeadlines(1001)); + Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(true)->modifiedSince(Date::sub("PT24H")), 2), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(1002)); + Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(true)->modifiedSince(Date::sub("PT24H"))->starred(true), 2), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(1003)); $this->assertMessage($out3[$a], $this->req($in3[$a]), "Test $a failed"); } } @@ -1990,7 +1990,7 @@ LONG_STRING; ]); $this->assertMessage($exp, $test); // test 'include_header' with an erroneous result - Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->limit(200)->reverse(true)->subscription(2112), $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->limit(200)->subscription(2112), $this->anything(), ["edited_date desc"])->thenThrow(new ExceptionInput("subjectMissing")); $test = $this->req($in[6]); $exp = $this->respGood([ ['id' => 2112, 'is_cat' => false, 'first_id' => 0], @@ -2005,7 +2005,7 @@ LONG_STRING; ]); $this->assertMessage($exp, $test); // test 'include_header' with skip - Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->reverse(true)->limit(1)->subscription(42), $this->anything())->thenReturn($this->generateHeadlines(1867)); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->limit(1)->subscription(42), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(1867)); $test = $this->req($in[8]); $exp = $this->respGood([ ['id' => 42, 'is_cat' => false, 'first_id' => 1867], From c6d241e653345bd160ceac0f6be98982d4789e1b Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 4 Apr 2019 17:57:12 -0400 Subject: [PATCH 086/142] Implement Fever item list --- lib/REST/Fever/API.php | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 5dcb9b0..0b79c48 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -225,4 +225,41 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } return $out; } + + protected function getItems(array $G): array { + $c = (new Context)->limit(50); + $reverse = false; + // handle the standard options + if ($G['with_ids']) { + $c->articles(explode(",", $G['with_ids'])); + } elseif ($G['since_id']) { + $c->oldestArticle($G['since_id'] + 1); + } elseif ($G['max_id']) { + $c->newestArticle($G['max_id'] - 1); + $reverse = true; + } + // handle the undocumented options + if ($G['group_ids']) { + $c->tags(explode(",", $G['group_ids'])); + } + if ($G['feed_ids']) { + $c->subscriptions(explode(",", $G['feed_ids'])); + } + // get results + $out = []; + $order = $reverse ? "id desc" : "id"; + foreach (Arsse::$db->articleList(Arsse::$user->id, $c, ["id", "subscription", "title", "author", "content", "url", "starred", "unread", "published_date"], [$order]) as $r) { + $out[] = [ + 'id' => (int) $r['id'], + 'feed_id' => (int) $r['subscription'], + 'title' => (string) $r['title'], + 'author' => (string) $r['author'], + 'html' => (string) $r['content'], + 'is_saved' => (int) $r['starred'], + 'is_read' => (int) !$r['unread'], + 'created_on_time' => Date::transform($r['published_date'], "unix", "sql"), + ]; + } + return $out; + } } From 7c85e837df2b6f92c64387bca82b39e965824219 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 4 Apr 2019 18:01:57 -0400 Subject: [PATCH 087/142] Documentation update --- CHANGELOG | 4 ++++ README.md | 1 - 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index edc4b0a..79a1be5 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -5,6 +5,10 @@ New features: - Support for the Fever protocol (see README.md for details) - Command line functionality for clearing a password, disabling the account - Command line options for dealing with Fever passwords +- Command line functionality for exporting subscriptions to OPML + +Bug fixes: +- Sort Tiny Tiny RSS special feeds according to special ordering Version 0.7.1 (2019-03-25) ========================== diff --git a/README.md b/README.md index ab7dc2e..6760072 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,6 @@ We are not aware of any other extensions to the TTRSS protocol. If you know of a - Full-text search is not yet employed with any database, including PostgreSQL - Article hashes are normally SHA1; The Arsse uses SHA256 hashes - Article attachments normally have unique IDs; The Arsse always gives attachments an ID of `"0"` -- The default sort order of the `getHeadlines` operation normally uses custom sorting for "special" feeds; The Arsse's default sort order is equivalent to `feed_dates` for all feeds - The `getCounters` operation normally omits members with zero unread; The Arsse includes everything to appease some clients #### Other notes From 982f09c9aa78674e13bfc467fab1756c57baba3e Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 4 Apr 2019 18:05:26 -0400 Subject: [PATCH 088/142] Upgrade notes --- UPGRADING | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/UPGRADING b/UPGRADING index a837396..ea3c84a 100644 --- a/UPGRADING +++ b/UPGRADING @@ -10,6 +10,12 @@ usually prudent: - If installing from source, update dependencies with: `composer install -o --no-dev` +Upgrading from 0.7.1 to 0.8.0 +============================= + +- The database schema has changed from rev4 to rev5; if upgrading the database + manually, apply the 4.sql file + Upgrading from 0.5.1 to 0.6.0 ============================= From 0752e9cf3dc0352e9a7af8de3ddcdba68d622b30 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 4 Apr 2019 19:37:48 -0400 Subject: [PATCH 089/142] Implement Fever sync --- lib/REST/Fever/API.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 0b79c48..62212f8 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -138,6 +138,12 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // TODO: implement hot links $out['inks'] = []; } + if ($G['unread_item_ids']) { + $out['unread_item_ids'] = $this->getItemIds((new Context)->unread(true)); + } + if ($G['saved_item_ids']) { + $out['saved_item_ids'] = $this->getItemIds((new Context)->starred(true)); + } return $out; } @@ -262,4 +268,12 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } return $out; } + + protected function getItemIds(Context $c = null): array { + $out = []; + foreach (Arsse::$db->articleList(Arsse::$user->id, $c) as $r) { + $out[] = (int) $r['id']; + } + return $out; + } } From 0ef606aa03caed72da3e1abe0b78e932f324956b Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 5 Apr 2019 08:20:05 -0400 Subject: [PATCH 090/142] Return string list of item IDs --- lib/REST/Fever/API.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 62212f8..2504463 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -269,11 +269,11 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { return $out; } - protected function getItemIds(Context $c = null): array { + protected function getItemIds(Context $c = null): string { $out = []; foreach (Arsse::$db->articleList(Arsse::$user->id, $c) as $r) { $out[] = (int) $r['id']; } - return $out; + return implode(",", $out); } } From e3d2215920538045001eacfcd770dcc1ef09b41b Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 5 Apr 2019 11:03:15 -0400 Subject: [PATCH 091/142] Style fixes --- lib/CLI.php | 1 + lib/Context/ExclusionContext.php | 2 +- lib/Database.php | 242 ++++++++++++------------- lib/Db/Driver.php | 14 +- lib/Db/SQLite3/PDODriver.php | 4 +- lib/Db/SQLite3/PDOStatement.php | 2 +- lib/Db/Statement.php | 2 +- lib/REST/TinyTinyRSS/API.php | 2 +- lib/REST/TinyTinyRSS/Search.php | 8 +- tests/cases/Database/SeriesArticle.php | 6 +- tests/cases/REST/Fever/TestAPI.php | 1 - 11 files changed, 145 insertions(+), 139 deletions(-) diff --git a/lib/CLI.php b/lib/CLI.php index 9693698..617a68b 100644 --- a/lib/CLI.php +++ b/lib/CLI.php @@ -128,6 +128,7 @@ USAGE_TEXT; } else { return $this->userAddOrSetPassword("passwordSet", $args[""], $args[""], $args["--oldpass"]); } + // no break case "unset-pass": if ($args['--fever']) { $this->getFever()->unregister($args[""]); diff --git a/lib/Context/ExclusionContext.php b/lib/Context/ExclusionContext.php index 7cf45cb..e7323ea 100644 --- a/lib/Context/ExclusionContext.php +++ b/lib/Context/ExclusionContext.php @@ -87,7 +87,7 @@ class ExclusionContext { $spec[$a] = null; } } - return array_values(array_unique(array_filter($spec, function ($v) { + return array_values(array_unique(array_filter($spec, function($v) { return !is_null($v); }))); } diff --git a/lib/Database.php b/lib/Database.php index fa754fd..69f1ae1 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -14,9 +14,9 @@ use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\ValueInfo; /** The high-level interface with the database - * + * * The database stores information on the following things: - * + * * - Users * - Subscriptions to feeds, which belong to users * - Folders, which belong to users and contain subscriptions @@ -28,9 +28,9 @@ use JKingWeb\Arsse\Misc\ValueInfo; * - Sessions, used by some protocols to identify users across periods of time * - Tokens, similar to sessions, but with more control over their properties * - Metadata, used internally by the server - * + * * The various methods of this class perform operations on these things, with - * each public method prefixed with the thing it concerns e.g. userRemove() + * each public method prefixed with the thing it concerns e.g. userRemove() * deletes a user from the database, and labelArticlesSet() changes a label's * associations with articles. There has been an effort to keep public method * names consistent throughout, but protected methods, having different @@ -54,7 +54,7 @@ class Database { public $db; /** Constructs the database interface - * + * * @param boolean $initialize Whether to attempt to upgrade the databse schema when constructing */ public function __construct($initialize = true) { @@ -71,7 +71,7 @@ class Database { return debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]['function']; } - /** Lists the available database drivers, as an associative array with + /** Lists the available database drivers, as an associative array with * fully-qualified class names as keys, and human-readable descriptions as values */ public static function driverList(): array { @@ -105,9 +105,9 @@ class Database { } /** Computes the column and value text of an SQL "SET" clause, validating arbitrary input against a whitelist - * + * * Returns an indexed array containing the clause text, an array of types, and another array of values - * + * * @param array $props An associative array containing untrusted data; keys are column names * @param array $valid An associative array containing a whitelist: keys are column names, and values are strings representing data types */ @@ -130,9 +130,9 @@ class Database { } /** Computes the contents of an SQL "IN()" clause, for each input value either embedding the value or producing a parameter placeholder - * + * * Returns an indexed array containing the clause text, an array of types, and an array of values. Note that the array of output values may not match the array of input values - * + * * @param array $values Arbitrary values * @param string $type A single data type applied to each value */ @@ -147,7 +147,7 @@ class Database { $params = []; $count = 0; $convType = Db\AbstractStatement::TYPE_NORM_MAP[Statement::TYPES[$type]]; - foreach($values as $v) { + foreach ($values as $v) { $v = ValueInfo::normalize($v, $convType, null, "sql"); if (is_null($v)) { // nulls are pointless to have @@ -176,11 +176,11 @@ class Database { } /** Computes basic LIKE-based text search constraints for use in a WHERE clause - * + * * Returns an indexed array containing the clause text, an array of types, and another array of values - * + * * The clause is structured such that all terms must be present across any of the columns - * + * * @param string[] $terms The terms to search for * @param string[] $cols The columns to match against; these are -not- sanitized, so much -not- come directly from user input * @param boolean $matchAny Whether the search is successful when it matches any (true) or all (false) terms @@ -194,7 +194,7 @@ class Database { $values = []; $like = $this->db->sqlToken("like"); $embedSet = sizeof($terms) > ((int) (self::LIMIT_SET_SIZE / sizeof($cols))); - foreach($terms as $term) { + foreach ($terms as $term) { $embedTerm = ($embedSet && strlen($term) <= self::LIMIT_SET_STRING_LENGTH); $term = str_replace(["%", "_", "^"], ["^%", "^_", "^^"], $term); $term = "%$term%"; @@ -249,7 +249,7 @@ class Database { } /** Adds a user to the database - * + * * @param string $user The user to add * @param string $passwordThe user's password in cleartext. It will be stored hashed */ @@ -298,7 +298,7 @@ class Database { } /** Sets the password of an existing user - * + * * @param string $user The user for whom to set the password * @param string $password The new password, in cleartext. The password will be stored hashed. If null is passed, the password is unset and authentication not possible */ @@ -329,10 +329,10 @@ class Database { } /** Explicitly removes a session from the database - * - * Sessions may also be invalidated as they expire, and then be automatically pruned. + * + * Sessions may also be invalidated as they expire, and then be automatically pruned. * This function can be used to explicitly invalidate a session after a user logs out - * + * * @param string $user The user who owns the session to be destroyed * @param string $id The identifier of the session to destroy */ @@ -346,7 +346,7 @@ class Database { } /** Resumes a session, returning available session data - * + * * This also has the side effect of refreshing the session if it is near its timeout */ public function sessionResume(string $id): array { @@ -380,8 +380,8 @@ class Database { return (($now + $diff) >= $expiry->getTimestamp()); } - /** Creates a new token for the given user in the given class - * + /** Creates a new token for the given user in the given class + * * @param string $user The user for whom to create the token * @param string $class The class of the token e.g. the protocol name * @param string|null $id The value of the token; if none is provided a UUID will be generated @@ -403,7 +403,7 @@ class Database { } /** Revokes one or all tokens for a user in a class - * + * * @param string $user The user who owns the token to be revoked * @param string $class The class of the token e.g. the protocol name * @param string|null $id The ID of a specific token, or null for all tokens in the class @@ -436,14 +436,14 @@ class Database { } /** Adds a folder for containing newsfeed subscriptions, returning an integer identifying the created folder - * + * * The $data array may contain the following keys: - * + * * - "name": A folder name, which must be a non-empty string not composed solely of whitespace; this key is required * - "parent": An integer (or null) identifying a parent folder; this key is optional - * + * * If a folder with the same name and parent already exists, this is an error - * + * * @param string $user The user who will own the folder * @param array $data An associative array defining the folder */ @@ -462,15 +462,15 @@ class Database { } /** Returns a result set listing a user's folders - * + * * Each record in the result set contains: - * + * * - "id": The folder identifier, an integer * - "name": The folder's name, a string * - "parent": The integer identifier of the folder's parent, or null * - "children": The number of child folders contained in the given folder - * - "feeds": The number of newsfeed subscriptions contained in the given folder, not including subscriptions in descendent folders - * + * - "feeds": The number of newsfeed subscriptions contained in the given folder, not including subscriptions in descendent folders + * * @param string $uer The user whose folders are to be listed * @param integer|null $parent Restricts the list to the descendents of the specified folder identifier * @param boolean $recursive Whether to list all descendents (true) or only direct children (false) @@ -505,9 +505,9 @@ class Database { } /** Deletes a folder from the database - * + * * Any descendent folders are also deleted, as are all newsfeed subscriptions contained in the deleted folder tree - * + * * @param string $user The user to whom the folder to be deleted belongs * @param integer $id The identifier of the folder to delete */ @@ -541,14 +541,14 @@ class Database { } /** Modifies the properties of a folder - * + * * The $data array must contain one or more of the following keys: - * + * * - "name": A new folder name, which must be a non-empty string not composed solely of whitespace * - "parent": An integer (or null) identifying a parent folder - * + * * If a folder with the new name and parent combination already exists, this is an error; it is also an error to move a folder to itself or one of its descendents - * + * * @param string $user The user who owns the folder to be modified * @param integer $id The identifier of the folder to be modified * @param array $data An associative array of properties to modify. Anything not specified will remain unchanged @@ -590,9 +590,9 @@ class Database { } /** Ensures the specified folder exists and raises an exception otherwise - * - * Returns an associative array containing the id, name, and parent of the folder if it exists - * + * + * Returns an associative array containing the id, name, and parent of the folder if it exists + * * @param string $user The user who owns the folder to be validated * @param integer|null $id The identifier of the folder to validate; null or zero represent the implied root folder * @param boolean $subject Whether the folder is the subject (true) rather than the object (false) of the operation being performed; this only affects the semantics of the error message if validation fails @@ -668,7 +668,7 @@ class Database { } /** Ensures a prospective folder name is valid, and optionally ensure it is not a duplicate if renamed - * + * * @param string $name The name to check * @param boolean $checkDuplicates Whether to also check if the new name would cause a collision * @param integer|null $parent The parent folder context in which to check for duplication @@ -695,7 +695,7 @@ class Database { } /** Adds a subscription to a newsfeed, and returns the numeric identifier of the added subscription - * + * * @param string $user The user which will own the subscription * @param string $url The URL of the newsfeed or discovery source * @param string $fetchUser The user name required to access the newsfeed, if applicable @@ -731,7 +731,7 @@ class Database { } /** Lists a user's subscriptions, returning various data - * + * * @param string $user The user whose subscriptions are to be listed * @param integer|null $folder The identifier of the folder under which to list subscriptions; by default the root folder is used * @param boolean $recursive Whether to list subscriptions of descendent folders as well as the selected folder @@ -802,8 +802,8 @@ class Database { } /** Deletes a subscription from the database - * - * This has the side effect of deleting all marks the user has set on articles + * + * This has the side effect of deleting all marks the user has set on articles * belonging to the newsfeed, but may not delete the articles themselves, as * other users may also be subscribed to the same newsfeed. There is also a * configurable retention period for newsfeeds @@ -823,7 +823,7 @@ class Database { } /** Retrieves data about a particular subscription, as an associative array with the following keys: - * + * * - "id": The numeric identifier of the subscription * - "feed": The numeric identifier of the underlying newsfeed * - "url": The URL of the newsfeed, after discovery and HTTP redirects @@ -855,14 +855,14 @@ class Database { } /** Modifies the properties of a subscription - * + * * The $data array must contain one or more of the following keys: - * + * * - "title": The title of the newsfeed * - "folder": The numeric identifier (or null) of the subscription's folder * - "pinned": Whether the subscription is pinned * - "order_type": Whether articles should be sorted in reverse cronological order (2), chronological order (1), or the default (0) - * + * * @param string $user The user whose subscription is to be modified * @param integer $id the numeric identifier of the subscription to modfify * @param array $data An associative array of properties to modify; any keys not specified will be left unchanged @@ -908,7 +908,7 @@ class Database { } /** Returns an indexed array listing the tags assigned to a subscription - * + * * @param string $user The user whose tags are to be listed * @param integer $id The numeric identifier of the subscription whose tags are to be listed * @param boolean $byName Whether to return the tag names (true) instead of the numeric tag identifiers (false) @@ -924,14 +924,14 @@ class Database { } /** Retrieves the URL of the icon for a subscription. - * + * * Note that while the $user parameter is optional, it - * is NOT recommended to omit it, as this can lead to - * leaks of private information. The parameter is only + * is NOT recommended to omit it, as this can lead to + * leaks of private information. The parameter is only * optional because this is required for Tiny Tiny RSS, * the original implementation of which leaks private * information due to a design flaw. - * + * * @param integer $id The numeric identifier of the subscription * @param string|null $user The user who owns the subscription being queried */ @@ -965,9 +965,9 @@ class Database { } /** Ensures the specified subscription exists and raises an exception otherwise - * + * * Returns an associative array containing the id of the subscription and the id of the underlying newsfeed - * + * * @param string $user The user who owns the subscription to be validated * @param integer $id The identifier of the subscription to validate * @param boolean $subject Whether the subscription is the subject (true) rather than the object (false) of the operation being performed; this only affects the semantics of the error message if validation fails @@ -990,7 +990,7 @@ class Database { } /** Attempts to refresh a newsfeed, returning an indication of success - * + * * @param integer $feedID The numerical identifier of the newsfeed to refresh * @param boolean $throwError Whether to throw an exception on failure in addition to storing error information in the database */ @@ -1146,7 +1146,7 @@ class Database { } /** Deletes orphaned newsfeeds from the database - * + * * Newsfeeds are orphaned if no users are subscribed to them. Deleting a newsfeed also deletes its articles */ public function feedCleanup(): bool { @@ -1167,14 +1167,14 @@ class Database { } /** Retrieves various identifiers for the latest $count articles in the given newsfeed. The identifiers are: - * + * * - "id": The database record key for the article * - "guid": The (theoretically) unique identifier for the article * - "edited": The time at which the article was last edited, per the newsfeed * - "url_title_hash": A cryptographic hash of the article URL and its title * - "url_content_hash": A cryptographic hash of the article URL and its content * - "title_content_hash": A cryptographic hash of the article title and its content - * + * * @param integer $feedID The numeric identifier of the feed * @param integer $count The number of records to return */ @@ -1187,14 +1187,14 @@ class Database { } /** Retrieves various identifiers for articles in the given newsfeed which match the input identifiers. The output identifiers are: - * + * * - "id": The database record key for the article * - "guid": The (theoretically) unique identifier for the article * - "edited": The time at which the article was last edited, per the newsfeed * - "url_title_hash": A cryptographic hash of the article URL and its title * - "url_content_hash": A cryptographic hash of the article URL and its content * - "title_content_hash": A cryptographic hash of the article title and its content - * + * * @param integer $feedID The numeric identifier of the feed * @param array $ids An array of GUIDs of articles * @param array $hashesUT An array of hashes of articles' URL and title @@ -1219,7 +1219,7 @@ class Database { } /** Returns an associative array of result column names and their SQL computations for article queries - * + * * This is used for whitelisting and defining both output column and order-by columns, as well as for resolution of some context options */ protected function articleColumns(): array { @@ -1250,9 +1250,9 @@ class Database { } /** Computes an SQL query to find and retrieve data about articles in the database - * + * * If an empty column list is supplied, a count of articles matching the context is queried instead - * + * * @param string $user The user whose articles are to be queried * @param Context $context The search context * @param array $cols The columns to request in the result set @@ -1270,7 +1270,7 @@ class Database { } if ($context->edition()) { $this->articleValidateEdition($user, $context->edition); - } + } if ($context->article()) { $this->articleValidateId($user, $context->article); } @@ -1356,7 +1356,7 @@ class Database { } elseif ($pair && $context->$pair()) { // option is paired with another which is also being used if ($op === ">=") { - $q->setWhere("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], [$context->$m, $context->$pair]); + $q->setWhere("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], [$context->$m, $context->$pair]); } else { // option has already been paired continue; @@ -1380,7 +1380,7 @@ class Database { } elseif ($pair && $context->not->$pair()) { // option is paired with another which is also being used if ($op === ">=") { - $q->setWhereNot("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], [$context->not->$m, $context->not->$pair]); + $q->setWhereNot("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], [$context->not->$m, $context->not->$pair]); } else { // option has already been paired continue; @@ -1458,7 +1458,7 @@ class Database { } } if ($seen) { - $spec = $opt['cte_name']."(".implode(",",$opt['cte_cols']).")"; + $spec = $opt['cte_name']."(".implode(",", $opt['cte_cols']).")"; $q->setCTE($spec, $opt['cte_body'], $opt['cte_types'], $opt['cte_values']); } } @@ -1525,9 +1525,9 @@ class Database { } /** Lists articles in the database which match a given query context - * + * * If an empty column list is supplied, a count of articles is returned instead - * + * * @param string $user The user whose articles are to be listed * @param Context $context The search context * @param array $fieldss The columns to return in the result set, any of: id, edition, url, title, author, content, guid, fingerprint, folder, subscription, feed, starred, unread, note, published_date, edited_date, modified_date, marked_date, subscription_title, media_url, media_type @@ -1576,7 +1576,7 @@ class Database { } /** Returns a count of articles which match the given query context - * + * * @param string $user The user whose articles are to be counted * @param Context $context The search context */ @@ -1590,13 +1590,13 @@ class Database { } /** Applies one or multiple modifications to all articles matching the given query context - * + * * The $data array enumerates the modifications to perform and must contain one or more of the following keys: - * + * * - "read": Whether the article should be marked as read (true) or unread (false) * - "starred": Whether the article should (true) or should not (false) be marked as starred/favourite * - "note": A string containing a freeform plain-text note for the article - * + * * @param string $user The user who owns the articles to be modified * @param array $data An associative array of properties to modify. Anything not specified will remain unchanged * @param Context $context The query context to match articles against @@ -1680,9 +1680,9 @@ class Database { } /** Returns statistics about the articles starred by the given user - * + * * The associative array returned has the following keys: - * + * * - "total": The count of all starred articles * - "unread": The count of starred articles which are unread * - "read": The count of starred articles which are read @@ -1704,7 +1704,7 @@ class Database { } /** Returns an indexed array listing the labels assigned to an article - * + * * @param string $user The user whose labels are to be listed * @param integer $id The numeric identifier of the article whose labels are to be listed * @param boolean $byName Whether to return the label names (true) instead of the numeric label identifiers (false) @@ -1768,9 +1768,9 @@ class Database { } /** Ensures the specified article exists and raises an exception otherwise - * - * Returns an associative array containing the id and latest edition of the article if it exists - * + * + * Returns an associative array containing the id and latest edition of the article if it exists + * * @param string $user The user who owns the article to be validated * @param integer $id The identifier of the article to validate */ @@ -1795,9 +1795,9 @@ class Database { } /** Ensures the specified article edition exists and raises an exception otherwise - * - * Returns an associative array containing the edition id, article id, and latest edition of the edition if it exists - * + * + * Returns an associative array containing the edition id, article id, and latest edition of the edition if it exists + * * @param string $user The user who owns the edition to be validated * @param integer $id The identifier of the edition to validate */ @@ -1848,9 +1848,9 @@ class Database { } /** Creates a label, and returns its numeric identifier - * + * * Labels are discrete objects in the database and can be associated with multiple articles; an article may in turn be associated with multiple labels - * + * * @param string $user The user who will own the created label * @param array $data An associative array defining the label's properties; currently only "name" is understood */ @@ -1867,14 +1867,14 @@ class Database { } /** Lists a user's article labels - * + * * The following keys are included in each record: - * + * * - "id": The label's numeric identifier * - "name" The label's textual name * - "articles": The count of articles which have the label assigned to them * - "read": How many of the total articles assigned to the label are read - * + * * @param string $user The user whose labels are to be listed * @param boolean $includeEmpty Whether to include (true) or supress (false) labels which have no articles assigned to them */ @@ -1911,9 +1911,9 @@ class Database { } /** Deletes a label from the database - * + * * Any articles associated with the label remains untouched - * + * * @param string $user The owner of the label to remove * @param integer|string $id The numeric identifier or name of the label * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) @@ -1933,14 +1933,14 @@ class Database { } /** Retrieves the properties of a label - * + * * The following keys are included in the output array: - * + * * - "id": The label's numeric identifier * - "name" The label's textual name * - "articles": The count of articles which have the label assigned to them * - "read": How many of the total articles assigned to the label are read - * + * * @param string $user The owner of the label to remove * @param integer|string $id The numeric identifier or name of the label * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) @@ -1981,7 +1981,7 @@ class Database { } /** Sets the properties of a label - * + * * @param string $user The owner of the label to query * @param integer|string $id The numeric identifier or name of the label * @param array $data An associative array defining the label's properties; currently only "name" is understood @@ -2013,7 +2013,7 @@ class Database { } /** Returns an indexed array of article identifiers assigned to a label - * + * * @param string $user The owner of the label to query * @param integer|string $id The numeric identifier or name of the label * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) @@ -2039,7 +2039,7 @@ class Database { } /** Makes or breaks associations between a given label and articles matching the given query context - * + * * @param string $user The owner of the label * @param integer|string $id The numeric identifier or name of the label * @param Context $context The query context matching the desired articles @@ -2080,9 +2080,9 @@ class Database { } /** Ensures the specified label identifier or name is valid (and optionally whether it exists) and raises an exception otherwise - * - * Returns an associative array containing the id, name of the label if it exists - * + * + * Returns an associative array containing the id, name of the label if it exists + * * @param string $user The user who owns the label to be validated * @param integer|string $id The numeric identifier or name of the label to validate * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) @@ -2127,9 +2127,9 @@ class Database { } /** Creates a tag, and returns its numeric identifier - * + * * Tags are discrete objects in the database and can be associated with multiple subscriptions; a subscription may in turn be associated with multiple tags - * + * * @param string $user The user who will own the created tag * @param array $data An associative array defining the tag's properties; currently only "name" is understood */ @@ -2146,13 +2146,13 @@ class Database { } /** Lists a user's subscription tags - * + * * The following keys are included in each record: - * + * * - "id": The tag's numeric identifier * - "name" The tag's textual name * - "subscriptions": The count of subscriptions which have the tag assigned to them - * + * * @param string $user The user whose tags are to be listed * @param boolean $includeEmpty Whether to include (true) or supress (false) tags which have no subscriptions assigned to them */ @@ -2177,14 +2177,14 @@ class Database { } /** Lists the associations between all tags and subscription - * + * * The following keys are included in each record: - * + * * - "tag_id": The tag's numeric identifier * - "tag_name" The tag's textual name * - "subscription_id": The numeric identifier of the associated subscription * - "subscription_name" The subscription's textual name - * + * * @param string $user The user whose tags are to be listed */ public function tagSummarize(string $user): Db\Result { @@ -2205,9 +2205,9 @@ class Database { } /** Deletes a tag from the database - * + * * Any subscriptions associated with the tag remains untouched - * + * * @param string $user The owner of the tag to remove * @param integer|string $id The numeric identifier or name of the tag * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) @@ -2227,13 +2227,13 @@ class Database { } /** Retrieves the properties of a tag - * + * * The following keys are included in the output array: - * + * * - "id": The tag's numeric identifier * - "name" The tag's textual name * - "subscriptions": The count of subscriptions which have the tag assigned to them - * + * * @param string $user The owner of the tag to remove * @param integer|string $id The numeric identifier or name of the tag * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) @@ -2262,7 +2262,7 @@ class Database { } /** Sets the properties of a tag - * + * * @param string $user The owner of the tag to query * @param integer|string $id The numeric identifier or name of the tag * @param array $data An associative array defining the tag's properties; currently only "name" is understood @@ -2294,7 +2294,7 @@ class Database { } /** Returns an indexed array of subscription identifiers assigned to a tag - * + * * @param string $user The owner of the tag to query * @param integer|string $id The numeric identifier or name of the tag * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) @@ -2320,7 +2320,7 @@ class Database { } /** Makes or breaks associations between a given tag and specified subscriptions - * + * * @param string $user The owner of the tag * @param integer|string $id The numeric identifier or name of the tag * @param integer[] $context The query context matching the desired subscriptions @@ -2339,7 +2339,7 @@ class Database { $q1 = $this->db->prepare( "UPDATE arsse_tag_members set assigned = ?, modified = CURRENT_TIMESTAMP - where tag = ? and assigned <> ? and subscription in (select id from arsse_subscriptions where owner = ? and id in ($inClause))", + where tag = ? and assigned <> ? and subscription in (select id from arsse_subscriptions where owner = ? and id in ($inClause))", "bool", "int", "bool", @@ -2370,9 +2370,9 @@ class Database { } /** Ensures the specified tag identifier or name is valid (and optionally whether it exists) and raises an exception otherwise - * - * Returns an associative array containing the id, name of the tag if it exists - * + * + * Returns an associative array containing the id, name of the tag if it exists + * * @param string $user The user who owns the tag to be validated * @param integer|string $id The numeric identifier or name of the tag to validate * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) diff --git a/lib/Db/Driver.php b/lib/Db/Driver.php index b0f572c..7f04dc6 100644 --- a/lib/Db/Driver.php +++ b/lib/Db/Driver.php @@ -20,7 +20,7 @@ interface Driver { public static function driverName(): string; /** Returns the version of the schema of the opened database; if uninitialized should return 0 - * + * * Normally the version is stored under the 'schema_version' key in the arsse_meta table, but another method may be used if appropriate */ public function schemaVersion(): int; @@ -32,7 +32,7 @@ interface Driver { public function begin(bool $lock = false): Transaction; /** Manually begins a real or synthetic transactions, with real or synthetic nesting, and returns its numeric ID - * + * * If the database backend does not implement savepoints, IDs must still be tracked as if it does */ public function savepointCreate(): int; @@ -44,7 +44,7 @@ interface Driver { public function savepointUndo(int $index = null): bool; /** Performs an in-place upgrade of the database schema - * + * * The driver may choose not to implement in-place upgrading, in which case an exception should be thrown */ public function schemaUpdate(int $to): bool; @@ -62,15 +62,15 @@ interface Driver { public function prepareArray(string $query, array $paramTypes): Statement; /** Reports whether the database character set is correct/acceptable - * + * * The backend must be able to accept and provide UTF-8 text; information may be stored in any encoding capable of representing the entire range of Unicode */ public function charsetAcceptable(): bool; /** Returns an implementation-dependent form of a reference SQL function or operator - * + * * The tokens the implementation must understand are: - * + * * - "greatest": the GREATEST function implemented by PostgreSQL and MySQL * - "nocase": the name of a general-purpose case-insensitive collation sequence * - "like": the case-insensitive LIKE operator @@ -78,7 +78,7 @@ interface Driver { public function sqlToken(string $token): string; /** Returns a string literal which is properly escaped to guard against SQL injections. Delimiters are included in the output string - * + * * This functionality should be avoided in favour of using statement parameters whenever possible */ public function literalString(string $str): string; diff --git a/lib/Db/SQLite3/PDODriver.php b/lib/Db/SQLite3/PDODriver.php index b1cff19..c6d7ad4 100644 --- a/lib/Db/SQLite3/PDODriver.php +++ b/lib/Db/SQLite3/PDODriver.php @@ -50,7 +50,7 @@ class PDODriver extends AbstractPDODriver { /** @codeCoverageIgnore */ public function exec(string $query): bool { - // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), + // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), // we have to retry ourselves in cases of schema changes // the SQLite3 class is not similarly affected $attempts = 0; @@ -68,7 +68,7 @@ class PDODriver extends AbstractPDODriver { /** @codeCoverageIgnore */ public function query(string $query): \JKingWeb\Arsse\Db\Result { - // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), + // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), // we have to retry ourselves in cases of schema changes // the SQLite3 class is not similarly affected $attempts = 0; diff --git a/lib/Db/SQLite3/PDOStatement.php b/lib/Db/SQLite3/PDOStatement.php index 166fe31..eb4fdfe 100644 --- a/lib/Db/SQLite3/PDOStatement.php +++ b/lib/Db/SQLite3/PDOStatement.php @@ -12,7 +12,7 @@ class PDOStatement extends \JKingWeb\Arsse\Db\PDOStatement { /** @codeCoverageIgnore */ public function runArray(array $values = []): \JKingWeb\Arsse\Db\Result { - // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), + // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), // we have to retry ourselves in cases of schema changes // the SQLite3 class is not similarly affected $attempts = 0; diff --git a/lib/Db/Statement.php b/lib/Db/Statement.php index b85ceca..0ed8685 100644 --- a/lib/Db/Statement.php +++ b/lib/Db/Statement.php @@ -24,7 +24,7 @@ interface Statement { 'str' => self::T_STRING, 'bool' => self::T_BOOLEAN, 'boolean' => self::T_BOOLEAN, - 'bit' => self::T_BOOLEAN, + 'bit' => self::T_BOOLEAN, 'strict int' => self::T_NOT_NULL + self::T_INTEGER, 'strict integer' => self::T_NOT_NULL + self::T_INTEGER, 'strict float' => self::T_NOT_NULL + self::T_FLOAT, diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index b274f20..26cf441 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -1499,7 +1499,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $order = ["edited_date desc"]; break; default: - // sort most recently marked for special feeds, newest first otherwise + // sort most recently marked for special feeds, newest first otherwise $order = (!$cat && ($id == self::FEED_READ || $id == self::FEED_STARRED)) ? ["marked_date desc"] : ["edited_date desc"]; break; } diff --git a/lib/REST/TinyTinyRSS/Search.php b/lib/REST/TinyTinyRSS/Search.php index 4ff634b..f791361 100644 --- a/lib/REST/TinyTinyRSS/Search.php +++ b/lib/REST/TinyTinyRSS/Search.php @@ -82,6 +82,7 @@ class Search { $state = self::STATE_IN_TOKEN_OR_TAG; continue 3; } + // no break case self::STATE_BEFORE_TOKEN_QUOTED: switch ($char) { case "": @@ -130,6 +131,7 @@ class Search { $state = self::STATE_IN_TOKEN_OR_TAG_QUOTED; continue 3; } + // no break case self::STATE_IN_DATE: while ($pos < $stop && $search[$pos] !== " ") { $buffer .= $search[$pos++]; @@ -169,6 +171,7 @@ class Search { $buffer .= $char; continue 3; } + // no break case self::STATE_IN_TOKEN: while ($pos < $stop && $search[$pos] !== " ") { $buffer .= $search[$pos++]; @@ -214,6 +217,7 @@ class Search { $buffer .= $char; continue 3; } + // no break case self::STATE_IN_TOKEN_OR_TAG: switch ($char) { case "": @@ -223,7 +227,7 @@ class Search { $flag_negative = false; $buffer = $tag = ""; continue 3; - case ":"; + case ":": $tag = $buffer; $buffer = ""; $state = self::STATE_IN_TOKEN; @@ -232,6 +236,7 @@ class Search { $buffer .= $char; continue 3; } + // no break case self::STATE_IN_TOKEN_OR_TAG_QUOTED: switch ($char) { case "": @@ -267,6 +272,7 @@ class Search { $buffer .= $char; continue 3; } + // no break default: throw new \Exception; // @codeCoverageIgnore } diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index d47f918..3871474 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -498,7 +498,7 @@ trait SeriesArticle { 'Excluded folder tree' => [(new Context)->not->folder(1), [1,2,3,4,19,20]], 'Excluding label ID 2' => [(new Context)->not->label(2), [2,3,4,6,7,8,19]], 'Excluding label "Fascinating"' => [(new Context)->not->labelName("Fascinating"), [2,3,4,6,7,8,19]], - 'Search 501 terms' => [(new Context)->searchTerms(array_merge(range(1,500),[str_repeat("a", 1000)])), []], + 'Search 501 terms' => [(new Context)->searchTerms(array_merge(range(1, 500), [str_repeat("a", 1000)])), []], 'With tag ID 1' => [(new Context)->tag(1), [5,6,7,8]], 'With tag ID 5' => [(new Context)->tag(5), [7,8,19,20]], 'With tag ID 1 or 5' => [(new Context)->tags([1,5]), [5,6,7,8,19,20]], @@ -1060,8 +1060,8 @@ trait SeriesArticle { } public function provideArrayContextOptions() { - foreach([ - "articles", "editions", + foreach ([ + "articles", "editions", "subscriptions", "foldersShallow", //"folders", "tags", "tagNames", "labels", "labelNames", "searchTerms", "authorTerms", "annotationTerms", diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index 272a25f..1986db0 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -26,7 +26,6 @@ use Zend\Diactoros\Response\EmptyResponse; /** @covers \JKingWeb\Arsse\REST\Fever\API */ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { - protected function v($value) { return $value; } From 4ce371ece69e0e0763870297fc25af263f1cce64 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 8 Apr 2019 18:41:56 -0400 Subject: [PATCH 092/142] Tests and fixes for Fever item listing --- lib/REST/Fever/API.php | 7 +- tests/cases/REST/Fever/TestAPI.php | 147 +++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 3 deletions(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 2504463..0849f61 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -238,11 +238,11 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // handle the standard options if ($G['with_ids']) { $c->articles(explode(",", $G['with_ids'])); - } elseif ($G['since_id']) { - $c->oldestArticle($G['since_id'] + 1); } elseif ($G['max_id']) { - $c->newestArticle($G['max_id'] - 1); + $c->latestArticle($G['max_id'] - 1); $reverse = true; + } elseif ($G['since_id']) { + $c->oldestArticle($G['since_id'] + 1); } // handle the undocumented options if ($G['group_ids']) { @@ -261,6 +261,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 'title' => (string) $r['title'], 'author' => (string) $r['author'], 'html' => (string) $r['content'], + 'url' => (string) $r['url'], 'is_saved' => (int) $r['starred'], 'is_read' => (int) !$r['unread'], 'created_on_time' => Date::transform($r['published_date'], "unix", "sql"), diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index 1986db0..490149f 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -26,6 +26,122 @@ use Zend\Diactoros\Response\EmptyResponse; /** @covers \JKingWeb\Arsse\REST\Fever\API */ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { + protected $articles = [ + 'db' => [ + [ + 'id' => 101, + 'url' => 'http://example.com/1', + 'title' => 'Article title 1', + 'author' => '', + 'content' => '

Article content 1

', + 'published_date' => '2000-01-01 00:00:00', + 'unread' => 1, + 'starred' => 0, + 'subscription' => 8, + ], + [ + 'id' => 102, + 'url' => 'http://example.com/2', + 'title' => 'Article title 2', + 'author' => '', + 'content' => '

Article content 2

', + 'published_date' => '2000-01-02 00:00:00', + 'unread' => 0, + 'starred' => 0, + 'subscription' => 8, + ], + [ + 'id' => 103, + 'url' => 'http://example.com/3', + 'title' => 'Article title 3', + 'author' => '', + 'content' => '

Article content 3

', + 'published_date' => '2000-01-03 00:00:00', + 'unread' => 1, + 'starred' => 1, + 'subscription' => 9, + ], + [ + 'id' => 104, + 'url' => 'http://example.com/4', + 'title' => 'Article title 4', + 'author' => '', + 'content' => '

Article content 4

', + 'published_date' => '2000-01-04 00:00:00', + 'unread' => 0, + 'starred' => 1, + 'subscription' => 9, + ], + [ + 'id' => 105, + 'url' => 'http://example.com/5', + 'title' => 'Article title 5', + 'author' => '', + 'content' => '

Article content 5

', + 'published_date' => '2000-01-05 00:00:00', + 'unread' => 1, + 'starred' => 0, + 'subscription' => 10, + ], + ], + 'rest' => [ + [ + 'id' => 101, + 'feed_id' => 8, + 'title' => 'Article title 1', + 'author' => '', + 'html' => '

Article content 1

', + 'url' => 'http://example.com/1', + 'is_saved' => 0, + 'is_read' => 0, + 'created_on_time' => 946684800, + ], + [ + 'id' => 102, + 'feed_id' => 8, + 'title' => 'Article title 2', + 'author' => '', + 'html' => '

Article content 2

', + 'url' => 'http://example.com/2', + 'is_saved' => 0, + 'is_read' => 1, + 'created_on_time' => 946771200, + ], + [ + 'id' => 103, + 'feed_id' => 9, + 'title' => 'Article title 3', + 'author' => '', + 'html' => '

Article content 3

', + 'url' => 'http://example.com/3', + 'is_saved' => 1, + 'is_read' => 0, + 'created_on_time' => 946857600, + ], + [ + 'id' => 104, + 'feed_id' => 9, + 'title' => 'Article title 4', + 'author' => '', + 'html' => '

Article content 4

', + 'url' => 'http://example.com/4', + 'is_saved' => 1, + 'is_read' => 1, + 'created_on_time' => 946944000, + ], + [ + 'id' => 105, + 'feed_id' => 10, + 'title' => 'Article title 5', + 'author' => '', + 'html' => '

Article content 5

', + 'url' => 'http://example.com/5', + 'is_saved' => 0, + 'is_read' => 0, + 'created_on_time' => 947030400, + ], + ], + ]; protected function v($value) { return $value; } @@ -204,4 +320,35 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { $act = $this->req("api&feeds"); $this->assertMessage($exp, $act); } + + /** @dataProvider provideItemListContexts */ + public function testListItems(string $url, Context $c, bool $desc) { + $fields = ["id", "subscription", "title", "author", "content", "url", "starred", "unread", "published_date"]; + $order = [$desc ? "id desc" : "id"]; + \Phake::when(Arsse::$db)->articleList->thenReturn(new Result($this->articles['db'])); + \Phake::when(Arsse::$db)->articleCount($this->anything())->thenReturn(1024); + $exp = new JsonResponse([ + 'items' => $this->articles['rest'], + 'total_items' => 1024, + ]); + $act = $this->req("api&$url"); + $this->assertMessage($exp, $act); + \Phake::verify(Arsse::$db)->articleList($this->anything(), $c, $fields, $order); + } + + public function provideItemListContexts() { + $c = (new Context)->limit(50); + return [ + ["items", (clone $c), false], + ["items&group_ids=1,2,3,4", (clone $c)->tags([1,2,3,4]), false], + ["items&feed_ids=1,2,3,4", (clone $c)->subscriptions([1,2,3,4]), false], + ["items&with_ids=1,2,3,4", (clone $c)->articles([1,2,3,4]), false], + ["items&since_id=1", (clone $c)->oldestArticle(2), false], + ["items&max_id=2", (clone $c)->latestArticle(1), true], + ["items&with_ids=1,2,3,4&max_id=6", (clone $c)->articles([1,2,3,4]), false], + ["items&with_ids=1,2,3,4&since_id=6", (clone $c)->articles([1,2,3,4]), false], + ["items&max_id=3&since_id=6", (clone $c)->latestArticle(2), true], + ["items&feed_ids=1,2,3,4&since_id=6", (clone $c)->subscriptions([1,2,3,4])->oldestArticle(7), false], + ]; + } } From e8f4732b1f32c49c68ded4313c68346749e98265 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 8 Apr 2019 19:15:12 -0400 Subject: [PATCH 093/142] Tests for saved and unread item ID lists --- tests/cases/REST/Fever/TestAPI.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index 490149f..ffd7a63 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -351,4 +351,19 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { ["items&feed_ids=1,2,3,4&since_id=6", (clone $c)->subscriptions([1,2,3,4])->oldestArticle(7), false], ]; } + + public function testListItemIds() { + $saved = [['id' => 1],['id' => 2],['id' => 3]]; + $unread = [['id' => 4],['id' => 5],['id' => 6]]; + \Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->starred(true))->thenReturn(new Result($saved)); + \Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->unread(true))->thenReturn(new Result($unread)); + $exp = new JsonResponse([ + 'saved_item_ids' => "1,2,3" + ]); + $this->assertMessage($exp, $this->req("api&saved_item_ids")); + $exp = new JsonResponse([ + 'unread_item_ids' => "4,5,6" + ]); + $this->assertMessage($exp, $this->req("api&unread_item_ids")); + } } From 98fc3f4940867593fe533fcd7446d5faea825742 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 8 Apr 2019 19:21:21 -0400 Subject: [PATCH 094/142] Test for hot links --- lib/REST/Fever/API.php | 2 +- tests/cases/REST/Fever/TestAPI.php | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 0849f61..8c14d69 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -136,7 +136,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } if ($G['links']) { // TODO: implement hot links - $out['inks'] = []; + $out['links'] = []; } if ($G['unread_item_ids']) { $out['unread_item_ids'] = $this->getItemIds((new Context)->unread(true)); diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index ffd7a63..686ef22 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -366,4 +366,12 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { ]); $this->assertMessage($exp, $this->req("api&unread_item_ids")); } + + public function testListHotLinks() { + // hot links are not actually implemented, so an empty array should be all we get + $exp = new JsonResponse([ + 'links' => [] + ]); + $this->assertMessage($exp, $this->req("api&links")); + } } From c783ec4357fd5864a9f2932a4f1bd427b4b93d2b Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 8 Apr 2019 20:58:45 -0400 Subject: [PATCH 095/142] Prototype XML output for Fever --- lib/REST/Fever/API.php | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 8c14d69..d1ad2b3 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -21,6 +21,7 @@ use JKingWeb\Arsse\REST\Exception405; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; use Zend\Diactoros\Response\JsonResponse; +use Zend\Diactoros\Response\XmlResponse; use Zend\Diactoros\Response\EmptyResponse; class API extends \JKingWeb\Arsse\REST\AbstractHandler { @@ -161,12 +162,47 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { protected function formatResponse(array $data, bool $xml): ResponseInterface { if ($xml) { - throw \Exception("Not implemented yet"); + $d = new \DOMDocument("1.0", "utf-8"); + $d->appendChild($this->makeXMLAssoc($data, $d->createElement("response"))); + return new XmlResponse($d->saveXML()); } else { return new JsonResponse($data, 200, [], \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); } } + protected function makeXMLAssoc(array $data, \DOMElement $p): \DOMElement { + $d = $p->ownerDocument; + foreach ($data as $k => $v) { + if (!is_array($v)) { + $p->appendChild($d->createElement($k, $v)); + } elseif (isset($v[0])) { + // this is a very simplistic check for an indexed array + // it would not pass muster in the face of generic data, + // but we'll assume our code produces only well-ordered + // indexed arrays + $p->appendChild($this->makeXMLIndexed($v, $d->createElement($k), substr($k, 0, strlen($k) - 1))); + } else { + $p->appendChild($this->makeXMLAssoc($v, $d->createElement($k))); + } + } + return $p; + } + + protected function makeXMLIndexed(array $data, \DOMElement $p, string $k): \DOMElement { + $d = $p->ownerDocument; + foreach ($data as $v) { + if (!is_array($v)) { + $p->appendChild($d->createElement($k, $v)); + } elseif (isset($v[0])) { + $p->appendChild($this->makeXMLIndexed($v, $d->createElement($k), substr($k, 0, strlen($k) - 1))); + } else { + $p->appendChild($this->makeXMLAssoc($v, $d->createElement($k))); + } + } + return $p; + + } + protected function logIn(string $hash): bool { // if HTTP authentication was successful and sessions are not enforced, proceed unconditionally if (isset(Arsse::$user->id) && !Arsse::$conf->userSessionEnforced) { From 15915a4393b6029f757e13c7928c2d6baad98f93 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 8 Apr 2019 23:31:22 -0400 Subject: [PATCH 096/142] Initial implementation of simple marks --- lib/REST/Fever/API.php | 75 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index d1ad2b3..29ca3c2 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -119,6 +119,18 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } protected function processRequest(array $out, array $G, array $P): array { + $listUnread = false; + $listSaved = false; + if ($P['unread_recently_read']) { + $this->setUnread(); + $listUnread = true; + } + if ($P['mark']) { + // depending on which mark are being made, + // either an 'unread_item_ids' or a + // 'saved_item_ids' entry will be added later + $listSaved = $this->setMarks($P, $listUnread); + } if ($G['feeds'] || $G['groups']) { if ($G['groups']) { $out['groups'] = $this->getGroups(); @@ -139,10 +151,10 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // TODO: implement hot links $out['links'] = []; } - if ($G['unread_item_ids']) { + if ($G['unread_item_ids'] || $listUnread) { $out['unread_item_ids'] = $this->getItemIds((new Context)->unread(true)); } - if ($G['saved_item_ids']) { + if ($G['saved_item_ids'] || $listSaved) { $out['saved_item_ids'] = $this->getItemIds((new Context)->starred(true)); } return $out; @@ -219,6 +231,65 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { return true; } + protected function setMarks(array $P, &$listUnread): bool { + $listSaved = false; + $c = new Context; + $id = $P['id']; + if ($P['before']) { + $c->notMarkedSince($P['before']); + } + switch ($P['mark']) { + case "item": + $c->article($id); + break; + case "group": + if ($id > 0) { + // group zero is the "Kindling" supergroup i.e. all feeds + $c->tag($id); + } elseif ($id < 0) { + // group negative-one is the "Sparks" supergroup i.e. no feeds + $c->not->folder(0); + } + break; + case "feed": + $c->subscription($id); + break; + default: + return $listSaved; + } + switch ($P['as']) { + case "read": + $data = ['read' => true]; + $listUnread = true; + break; + case "unread": + // this option is undocumented, but valid + $data = ['read' => false]; + $listUnread = true; + break; + case "saved": + $data = ['starred' => true]; + $listSaved = true; + break; + case "unsaved": + $data = ['starred' => false]; + $listSaved = true; + break; + default: + return $listSaved; + } + try { + Arsse::$db->articleMark(Arsse::$user->id, $data, $c); + } catch (ExceptionInput $e) { + // ignore any errors + } + return $listSaved; + } + + protected function setUnread() { + // stub + } + protected function getRefreshTime() { return Date::transform(Arsse::$db->subscriptionRefreshed(Arsse::$user->id), "unix"); } From 61abf7ee7c876ae8b67a0f343a5ab036f1feffdd Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 9 Apr 2019 16:15:36 -0400 Subject: [PATCH 097/142] Upgrade to Diactoros 2.x --- arsse.php | 2 +- composer.json | 11 +-- composer.lock | 204 ++++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 190 insertions(+), 27 deletions(-) diff --git a/arsse.php b/arsse.php index 407be03..0cfa0ae 100644 --- a/arsse.php +++ b/arsse.php @@ -25,7 +25,7 @@ if (\PHP_SAPI === "cli") { $conf = file_exists(BASE."config.php") ? new Conf(BASE."config.php") : new Conf; Arsse::load($conf); // handle Web requests - $emitter = new \Zend\Diactoros\Response\SapiEmitter(); + $emitter = new \Zend\HttpHandlerRunner\Emitter\SapiEmitter; $response = (new REST)->dispatch(); $emitter->emit($response); } diff --git a/composer.json b/composer.json index 0f5570c..1a607c3 100644 --- a/composer.json +++ b/composer.json @@ -18,15 +18,16 @@ ], "require": { - "php": "^7.0", + "php": "7.*", "ext-intl": "*", "ext-json": "*", "ext-hash": "*", "p3k/picofeed": "0.1.*", - "hosteurope/password-generator": "^1.0", - "docopt/docopt": "^1.0", - "jkingweb/druuid": "^3.0", - "zendframework/zend-diactoros": "^1.6" + "hosteurope/password-generator": "1.*", + "docopt/docopt": "1.*", + "jkingweb/druuid": "3.*", + "zendframework/zend-diactoros": "2.*", + "zendframework/zend-httphandlerrunner": "1.*" }, "require-dev": { "bamarni/composer-bin-plugin": "*" diff --git a/composer.lock b/composer.lock index b5e5d38..0a7f073 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d7a6a00be3d97c11d09ec4d4e56d36e0", + "content-hash": "bd427d25f07432e40d396060907cf1e3", "packages": [ { "name": "docopt/docopt", @@ -190,6 +190,58 @@ "homepage": "https://github.com/miniflux/picoFeed", "time": "2017-11-30T00:16:58+00:00" }, + { + "name": "psr/http-factory", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "378bfe27931ecc54ff824a20d6f6bfc303bbd04c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/378bfe27931ecc54ff824a20d6f6bfc303bbd04c", + "reference": "378bfe27931ecc54ff824a20d6f6bfc303bbd04c", + "shasum": "" + }, + "require": { + "php": ">=7.0.0", + "psr/http-message": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "time": "2018-07-30T21:54:04+00:00" + }, { "name": "psr/http-message", "version": "1.0.1", @@ -240,40 +292,96 @@ ], "time": "2016-08-06T14:39:51+00:00" }, + { + "name": "psr/http-server-handler", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "aff2f80e33b7f026ec96bb42f63242dc50ffcae7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/aff2f80e33b7f026ec96bb42f63242dc50ffcae7", + "reference": "aff2f80e33b7f026ec96bb42f63242dc50ffcae7", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "time": "2018-10-30T16:46:14+00:00" + }, { "name": "zendframework/zend-diactoros", - "version": "1.8.6", + "version": "2.1.1", "source": { "type": "git", "url": "https://github.com/zendframework/zend-diactoros.git", - "reference": "20da13beba0dde8fb648be3cc19765732790f46e" + "reference": "c3c330192bc9cc51b7e9ce968ff721dc32ffa986" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/zendframework/zend-diactoros/zipball/20da13beba0dde8fb648be3cc19765732790f46e", - "reference": "20da13beba0dde8fb648be3cc19765732790f46e", + "url": "https://api.github.com/repos/zendframework/zend-diactoros/zipball/c3c330192bc9cc51b7e9ce968ff721dc32ffa986", + "reference": "c3c330192bc9cc51b7e9ce968ff721dc32ffa986", "shasum": "" }, "require": { - "php": "^5.6 || ^7.0", + "php": "^7.1", + "psr/http-factory": "^1.0", "psr/http-message": "^1.0" }, "provide": { + "psr/http-factory-implementation": "1.0", "psr/http-message-implementation": "1.0" }, "require-dev": { "ext-dom": "*", "ext-libxml": "*", + "http-interop/http-factory-tests": "^0.5.0", "php-http/psr7-integration-tests": "dev-master", - "phpunit/phpunit": "^5.7.16 || ^6.0.8 || ^7.2.7", - "zendframework/zend-coding-standard": "~1.0" + "phpunit/phpunit": "^7.0.2", + "zendframework/zend-coding-standard": "~1.0.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.8.x-dev", - "dev-develop": "1.9.x-dev", - "dev-release-2.0": "2.0.x-dev" + "dev-master": "2.1.x-dev", + "dev-develop": "2.2.x-dev", + "dev-release-1.8": "1.8.x-dev" } }, "autoload": { @@ -293,16 +401,70 @@ }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-2-Clause" + "BSD-3-Clause" ], "description": "PSR HTTP Message implementations", - "homepage": "https://github.com/zendframework/zend-diactoros", "keywords": [ "http", "psr", "psr-7" ], - "time": "2018-09-05T19:29:37+00:00" + "time": "2019-01-05T20:13:32+00:00" + }, + { + "name": "zendframework/zend-httphandlerrunner", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/zendframework/zend-httphandlerrunner.git", + "reference": "75fb12751fe9d6e392cce1ee0d687dacae2db787" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/zendframework/zend-httphandlerrunner/zipball/75fb12751fe9d6e392cce1ee0d687dacae2db787", + "reference": "75fb12751fe9d6e392cce1ee0d687dacae2db787", + "shasum": "" + }, + "require": { + "php": "^7.1", + "psr/http-message": "^1.0", + "psr/http-message-implementation": "^1.0", + "psr/http-server-handler": "^1.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.0.2", + "zendframework/zend-coding-standard": "~1.0.0", + "zendframework/zend-diactoros": "^1.7 || ^2.1.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev", + "dev-develop": "1.2.x-dev" + }, + "zf": { + "config-provider": "Zend\\HttpHandlerRunner\\ConfigProvider" + } + }, + "autoload": { + "psr-4": { + "Zend\\HttpHandlerRunner\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Execute PSR-15 RequestHandlerInterface instances and emit responses they generate.", + "keywords": [ + "ZendFramework", + "components", + "expressive", + "psr-15", + "psr-7", + "zf" + ], + "time": "2019-02-19T18:20:34+00:00" }, { "name": "zendframework/zendxml", @@ -354,16 +516,16 @@ "packages-dev": [ { "name": "bamarni/composer-bin-plugin", - "version": "v1.2.0", + "version": "v1.3.0", "source": { "type": "git", "url": "https://github.com/bamarni/composer-bin-plugin.git", - "reference": "62fef740245a85f00665e81ea8f0aa0b72afe6e7" + "reference": "67f9d314dc7ecf7245b8637906e151ccc62b8d24" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bamarni/composer-bin-plugin/zipball/62fef740245a85f00665e81ea8f0aa0b72afe6e7", - "reference": "62fef740245a85f00665e81ea8f0aa0b72afe6e7", + "url": "https://api.github.com/repos/bamarni/composer-bin-plugin/zipball/67f9d314dc7ecf7245b8637906e151ccc62b8d24", + "reference": "67f9d314dc7ecf7245b8637906e151ccc62b8d24", "shasum": "" }, "require": { @@ -371,7 +533,7 @@ }, "require-dev": { "composer/composer": "dev-master", - "symfony/console": "^2.5 || ^3.0" + "symfony/console": "^2.5 || ^3.0 || ^4.0" }, "type": "composer-plugin", "extra": { @@ -389,7 +551,7 @@ "license": [ "MIT" ], - "time": "2017-09-11T13:13:58+00:00" + "time": "2019-03-17T12:38:04+00:00" } ], "aliases": [], @@ -398,7 +560,7 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": "^7.0", + "php": "7.*", "ext-intl": "*", "ext-json": "*", "ext-hash": "*" From 52bc5fbda6124aaed1cf19fc9e7f4e1f04fcb6e9 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 10 Apr 2019 09:48:28 -0400 Subject: [PATCH 098/142] Tests for simple marking --- lib/REST/Fever/API.php | 7 ++- tests/cases/REST/Fever/TestAPI.php | 68 ++++++++++++++++++++++++++---- 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 29ca3c2..d472b11 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -125,7 +125,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $this->setUnread(); $listUnread = true; } - if ($P['mark']) { + if ($P['mark'] && $P['as'] && is_int($P['id'])) { // depending on which mark are being made, // either an 'unread_item_ids' or a // 'saved_item_ids' entry will be added later @@ -244,11 +244,14 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { break; case "group": if ($id > 0) { - // group zero is the "Kindling" supergroup i.e. all feeds + // concrete groups $c->tag($id); } elseif ($id < 0) { // group negative-one is the "Sparks" supergroup i.e. no feeds $c->not->folder(0); + } else { + // group zero is the "Kindling" supergroup i.e. all feeds + // nothing need to be done for this } break; case "feed": diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index 686ef22..9a899c0 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -7,16 +7,11 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\REST\Fever; use JKingWeb\Arsse\Arsse; -use JKingWeb\Arsse\Conf; use JKingWeb\Arsse\User; use JKingWeb\Arsse\Database; -use JKingWeb\Arsse\Service; -use JKingWeb\Arsse\REST\Request; use JKingWeb\Arsse\Test\Result; -use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Db\ExceptionInput; -use JKingWeb\Arsse\User\Exception as UserException; use JKingWeb\Arsse\Db\Transaction; use JKingWeb\Arsse\REST\Fever\API; use Psr\Http\Message\ResponseInterface; @@ -161,9 +156,8 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { if (is_array($dataPost)) { $req = $req->withParsedBody($dataPost); } else { - $body = $req->getBody(); - $body->write($dataPost); - $req = $req->withBody($body); + parse_str($dataPost, $arr); + $req = $req->withParsedBody($arr); } if (isset($user)) { if (strlen($user)) { @@ -319,7 +313,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { ]); $act = $this->req("api&feeds"); $this->assertMessage($exp, $act); - } + } /** @dataProvider provideItemListContexts */ public function testListItems(string $url, Context $c, bool $desc) { @@ -374,4 +368,60 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { ]); $this->assertMessage($exp, $this->req("api&links")); } + + /** @dataProvider provideMarkingContexts */ + public function testSetMarks(string $post, Context $c, array $data, array $out) { + $saved = [['id' => 1],['id' => 2],['id' => 3]]; + $unread = [['id' => 4],['id' => 5],['id' => 6]]; + \Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->starred(true))->thenReturn(new Result($saved)); + \Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->unread(true))->thenReturn(new Result($unread)); + \Phake::when(Arsse::$db)->articleMark->thenReturn(0); + \Phake::when(Arsse::$db)->articleMark($this->anything(), $this->anything(), (new Context)->article(2112))->thenThrow(new \JKingWeb\Arsse\Db\ExceptionInput("subjectMissing")); + $exp = new JsonResponse($out); + $act = $this->req("api", $post); + $this->assertMessage($exp, $act); + if ($c && $data) { + \Phake::verify(Arsse::$db)->articleMark($this->anything(), $data, $c); + } else { + \Phake::verify(Arsse::$db, \Phake::times(0))->articleMark; + } + } + + public function provideMarkingContexts() { + $markRead = ['read' => true]; + $markUnread = ['read' => false]; + $markSaved = ['starred' => true]; + $markUnsaved = ['starred' => false]; + $listSaved = ['saved_item_ids' => "1,2,3"]; + $listUnread = ['unread_item_ids' => "4,5,6"]; + return [ + ["mark=item&as=read&id=5", (new Context)->article(5), $markRead, $listUnread], + ["mark=item&as=unread&id=42", (new Context)->article(42), $markUnread, $listUnread], + ["mark=item&as=read&id=2112", (new Context)->article(2112), $markRead, $listUnread], // article doesn't exist + ["mark=item&as=saved&id=5", (new Context)->article(5), $markSaved, $listSaved], + ["mark=item&as=unsaved&id=42", (new Context)->article(42), $markUnsaved, $listSaved], + ["mark=feed&as=read&id=5", (new Context)->subscription(5), $markRead, $listUnread], + ["mark=feed&as=unread&id=42", (new Context)->subscription(42), $markUnread, $listUnread], + ["mark=feed&as=saved&id=5", (new Context)->subscription(5), $markSaved, $listSaved], + ["mark=feed&as=unsaved&id=42", (new Context)->subscription(42), $markUnsaved, $listSaved], + ["mark=group&as=read&id=5", (new Context)->tag(5), $markRead, $listUnread], + ["mark=group&as=unread&id=42", (new Context)->tag(42), $markUnread, $listUnread], + ["mark=group&as=saved&id=5", (new Context)->tag(5), $markSaved, $listSaved], + ["mark=group&as=unsaved&id=42", (new Context)->tag(42), $markUnsaved, $listSaved], + ["mark=item&as=invalid&id=42", new Context, [], []], + ["mark=invalid&as=unread&id=42", new Context, [], []], + ["mark=group&as=read&id=0", (new Context), $markRead, $listUnread], + ["mark=group&as=unread&id=0", (new Context), $markUnread, $listUnread], + ["mark=group&as=saved&id=0", (new Context), $markSaved, $listSaved], + ["mark=group&as=unsaved&id=0", (new Context), $markUnsaved, $listSaved], + ["mark=group&as=read&id=-1", (new Context)->not->folder(0), $markRead, $listUnread], + ["mark=group&as=unread&id=-1", (new Context)->not->folder(0), $markUnread, $listUnread], + ["mark=group&as=saved&id=-1", (new Context)->not->folder(0), $markSaved, $listSaved], + ["mark=group&as=unsaved&id=-1", (new Context)->not->folder(0), $markUnsaved, $listSaved], + ["mark=group&as=read&id=-1&before=946684800", (new Context)->not->folder(0)->notMarkedSince("2000-01-01T00:00:00"), $markRead, $listUnread], + ["mark=item&as=unread", new Context, [], []], + ["mark=item&id=6", new Context, [], []], + ["as=unread&id=6", new Context, [], []], + ]; + } } From afb95e53b025554aade539e842dc6c0c8b164011 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 10 Apr 2019 10:21:14 -0400 Subject: [PATCH 099/142] Initial implementation of read-undo --- lib/REST/Fever/API.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index d472b11..2d0f984 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -290,7 +290,19 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } protected function setUnread() { - // stub + $lastUnread = Arsse::$db->articleList(Arsse::$user->id, (new Context)->limit(1), ["marked_date"], ["marked_date desc"])->getValue(); + if (!$lastUnread) { + // there are no articles + return; + } + // Fever takes the date of the last read article less fifteen seconds as a cut-off. + // We take the date of last mark (whether it be read, unread, saved, unsaved), which + // not actually signify a mark, but we'll otherwise also count back fifteen seconds + $c = new Context; + $lastUnread = Date::normalize($lastUnread, "sql"); + $since = Date::sub("DT15S", $lastUnread); + $c->unread(false)->markedSince($since); + Arsse::$db->articleMark(Arsse::$user->id, ['read' => false], $c); } protected function getRefreshTime() { From 8532c581a8c638a747e01e0655cb12bda52687ed Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 10 Apr 2019 10:51:02 -0400 Subject: [PATCH 100/142] Handle OPTIONS requests in Fever --- lib/REST/Fever/API.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 2d0f984..baa501f 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -63,8 +63,10 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } switch ($req->getMethod()) { case "OPTIONS": - // do stuff - break; + return new EmptyResponse(204, [ + 'Allow' => "POST", + 'Accept' => "application/x-www-form-urlencoded", + ]); case "POST": if (strlen($req->getHeaderLine("Content-Type")) && $req->getHeaderLine("Content-Type") !== "application/x-www-form-urlencoded") { return new EmptyResponse(415, ['Accept' => "application/x-www-form-urlencoded"]); @@ -297,7 +299,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } // Fever takes the date of the last read article less fifteen seconds as a cut-off. // We take the date of last mark (whether it be read, unread, saved, unsaved), which - // not actually signify a mark, but we'll otherwise also count back fifteen seconds + // may not actually signify a mark, but we'll otherwise also count back fifteen seconds $c = new Context; $lastUnread = Date::normalize($lastUnread, "sql"); $since = Date::sub("DT15S", $lastUnread); From efd8492573f84b89a991cb4ca4d2750872f3ca2f Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 10 Apr 2019 15:07:34 -0400 Subject: [PATCH 101/142] Tests for various invalid requests --- tests/cases/REST/Fever/TestAPI.php | 38 ++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index 9a899c0..e077197 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -141,14 +141,15 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { return $value; } - protected function req($dataGet, $dataPost = "", string $method = "POST", string $type = null, string $url = "", string $user = null): ResponseInterface { + protected function req($dataGet, $dataPost = "", string $method = "POST", string $type = null, string $url = "", string $user = null): ServerRequest { $url = "/fever/".$url; + $type = $type ?? "application/x-www-form-urlencoded"; $server = [ 'REQUEST_METHOD' => $method, 'REQUEST_URI' => $url, - 'HTTP_CONTENT_TYPE' => $type ?? "application/x-www-form-urlencoded", + 'HTTP_CONTENT_TYPE' => $type, ]; - $req = new ServerRequest($server, [], $url, $method, "php://memory"); + $req = new ServerRequest($server, [], $url, $method, "php://memory", ['Content-Type' => $type]); if (!is_array($dataGet)) { parse_str($dataGet, $dataGet); } @@ -166,7 +167,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { $req = $req->withAttribute("authenticationFailed", true); } } - return $this->h->dispatch($req); + return $req; } public function setUp() { @@ -205,7 +206,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { \Phake::when($this->h)->processRequest->thenReturnCallback(function($out, $G, $P) { return $out; }); - $act = $this->req($dataGet, $dataPost, "POST", null, "", $httpUser); + $act = $this->h->dispatch($this->req($dataGet, $dataPost, "POST", null, "", $httpUser)); $this->assertMessage($exp, $act); } @@ -284,7 +285,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { ['group_id' => 2, 'feed_ids' => "1,3"], ], ]); - $act = $this->req("api&groups"); + $act = $this->h->dispatch($this->req("api&groups")); $this->assertMessage($exp, $act); } @@ -311,7 +312,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { ['group_id' => 2, 'feed_ids' => "1,3"], ], ]); - $act = $this->req("api&feeds"); + $act = $this->h->dispatch($this->req("api&feeds")); $this->assertMessage($exp, $act); } @@ -325,7 +326,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { 'items' => $this->articles['rest'], 'total_items' => 1024, ]); - $act = $this->req("api&$url"); + $act = $this->h->dispatch($this->req("api&$url")); $this->assertMessage($exp, $act); \Phake::verify(Arsse::$db)->articleList($this->anything(), $c, $fields, $order); } @@ -354,11 +355,11 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { $exp = new JsonResponse([ 'saved_item_ids' => "1,2,3" ]); - $this->assertMessage($exp, $this->req("api&saved_item_ids")); + $this->assertMessage($exp, $this->h->dispatch($this->req("api&saved_item_ids"))); $exp = new JsonResponse([ 'unread_item_ids' => "4,5,6" ]); - $this->assertMessage($exp, $this->req("api&unread_item_ids")); + $this->assertMessage($exp, $this->h->dispatch($this->req("api&unread_item_ids"))); } public function testListHotLinks() { @@ -366,7 +367,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { $exp = new JsonResponse([ 'links' => [] ]); - $this->assertMessage($exp, $this->req("api&links")); + $this->assertMessage($exp, $this->h->dispatch($this->req("api&links"))); } /** @dataProvider provideMarkingContexts */ @@ -378,7 +379,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { \Phake::when(Arsse::$db)->articleMark->thenReturn(0); \Phake::when(Arsse::$db)->articleMark($this->anything(), $this->anything(), (new Context)->article(2112))->thenThrow(new \JKingWeb\Arsse\Db\ExceptionInput("subjectMissing")); $exp = new JsonResponse($out); - $act = $this->req("api", $post); + $act = $this->h->dispatch($this->req("api", $post)); $this->assertMessage($exp, $act); if ($c && $data) { \Phake::verify(Arsse::$db)->articleMark($this->anything(), $data, $c); @@ -424,4 +425,17 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { ["as=unread&id=6", new Context, [], []], ]; } + + /** @dataProvider provideInvalidRequests */ + public function testSendInvalidRequests(ServerRequest $req, ResponseInterface $exp) { + $this->assertMessage($exp, $this->h->dispatch($req)); + } + + public function provideInvalidRequests() { + return [ + 'Not an API request' => [$this->req(""), new EmptyResponse(404)], + 'Wrong method' => [$this->req("api", "", "GET"), new EmptyResponse(405, ['Allow' => "OPTIONS,POST"])], + 'Wrong content type' => [$this->req("api", "", "POST", "application/json"), new EmptyResponse(415, ['Accept' => "application/x-www-form-urlencoded"])], + ]; + } } From c55a960b856f548c55b6cd3854dd502b9559e947 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 10 Apr 2019 15:14:45 -0400 Subject: [PATCH 102/142] Slight cleanup --- lib/REST/TinyTinyRSS/API.php | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 26cf441..b5b610f 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -8,11 +8,9 @@ namespace JKingWeb\Arsse\REST\TinyTinyRSS; use JKingWeb\Arsse\Feed; use JKingWeb\Arsse\Arsse; -use JKingWeb\Arsse\Database; -use JKingWeb\Arsse\User; -use JKingWeb\Arsse\Service; -use JKingWeb\Arsse\Misc\Date; +use JKingWeb\Arsse\Service;; use JKingWeb\Arsse\Context\Context; +use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\ValueInfo; use JKingWeb\Arsse\AbstractException; use JKingWeb\Arsse\ExceptionType; @@ -23,7 +21,6 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; use Zend\Diactoros\Response\JsonResponse as Response; use Zend\Diactoros\Response\EmptyResponse; -use Robo\Task\Archive\Pack; class API extends \JKingWeb\Arsse\REST\AbstractHandler { const LEVEL = 14; // emulated API level From daeff63239d2235cd29fa8df9b77ab4127bc0688 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 10 Apr 2019 16:01:58 -0400 Subject: [PATCH 103/142] Test basic Fever responses --- tests/cases/REST/Fever/TestAPI.php | 47 ++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index e077197..aa7bc75 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -18,6 +18,7 @@ use Psr\Http\Message\ResponseInterface; use Zend\Diactoros\ServerRequest; use Zend\Diactoros\Response\JsonResponse; use Zend\Diactoros\Response\EmptyResponse; +use PHPUnit\Util\Json; /** @covers \JKingWeb\Arsse\REST\Fever\API */ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { @@ -321,14 +322,14 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { $fields = ["id", "subscription", "title", "author", "content", "url", "starred", "unread", "published_date"]; $order = [$desc ? "id desc" : "id"]; \Phake::when(Arsse::$db)->articleList->thenReturn(new Result($this->articles['db'])); - \Phake::when(Arsse::$db)->articleCount($this->anything())->thenReturn(1024); + \Phake::when(Arsse::$db)->articleCount(Arsse::$user->id)->thenReturn(1024); $exp = new JsonResponse([ 'items' => $this->articles['rest'], 'total_items' => 1024, ]); $act = $this->h->dispatch($this->req("api&$url")); $this->assertMessage($exp, $act); - \Phake::verify(Arsse::$db)->articleList($this->anything(), $c, $fields, $order); + \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, $c, $fields, $order); } public function provideItemListContexts() { @@ -350,8 +351,8 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { public function testListItemIds() { $saved = [['id' => 1],['id' => 2],['id' => 3]]; $unread = [['id' => 4],['id' => 5],['id' => 6]]; - \Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->starred(true))->thenReturn(new Result($saved)); - \Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->unread(true))->thenReturn(new Result($unread)); + \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->starred(true))->thenReturn(new Result($saved)); + \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->unread(true))->thenReturn(new Result($unread)); $exp = new JsonResponse([ 'saved_item_ids' => "1,2,3" ]); @@ -374,15 +375,15 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { public function testSetMarks(string $post, Context $c, array $data, array $out) { $saved = [['id' => 1],['id' => 2],['id' => 3]]; $unread = [['id' => 4],['id' => 5],['id' => 6]]; - \Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->starred(true))->thenReturn(new Result($saved)); - \Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->unread(true))->thenReturn(new Result($unread)); + \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->starred(true))->thenReturn(new Result($saved)); + \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->unread(true))->thenReturn(new Result($unread)); \Phake::when(Arsse::$db)->articleMark->thenReturn(0); - \Phake::when(Arsse::$db)->articleMark($this->anything(), $this->anything(), (new Context)->article(2112))->thenThrow(new \JKingWeb\Arsse\Db\ExceptionInput("subjectMissing")); + \Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), (new Context)->article(2112))->thenThrow(new \JKingWeb\Arsse\Db\ExceptionInput("subjectMissing")); $exp = new JsonResponse($out); $act = $this->h->dispatch($this->req("api", $post)); $this->assertMessage($exp, $act); if ($c && $data) { - \Phake::verify(Arsse::$db)->articleMark($this->anything(), $data, $c); + \Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $data, $c); } else { \Phake::verify(Arsse::$db, \Phake::times(0))->articleMark; } @@ -419,7 +420,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { ["mark=group&as=unread&id=-1", (new Context)->not->folder(0), $markUnread, $listUnread], ["mark=group&as=saved&id=-1", (new Context)->not->folder(0), $markSaved, $listSaved], ["mark=group&as=unsaved&id=-1", (new Context)->not->folder(0), $markUnsaved, $listSaved], - ["mark=group&as=read&id=-1&before=946684800", (new Context)->not->folder(0)->notMarkedSince("2000-01-01T00:00:00"), $markRead, $listUnread], + ["mark=group&as=read&id=-1&before=946684800", (new Context)->not->folder(0)->notMarkedSince("2000-01-01T00:00:00Z"), $markRead, $listUnread], ["mark=item&as=unread", new Context, [], []], ["mark=item&id=6", new Context, [], []], ["as=unread&id=6", new Context, [], []], @@ -438,4 +439,32 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { 'Wrong content type' => [$this->req("api", "", "POST", "application/json"), new EmptyResponse(415, ['Accept' => "application/x-www-form-urlencoded"])], ]; } + + public function testMakeABaseQuery() { + $this->h = \Phake::partialMock(API::class); + \Phake::when($this->h)->logIn->thenReturn(true); + \Phake::when(Arsse::$db)->subscriptionRefreshed(Arsse::$user->id)->thenReturn(new \DateTimeImmutable("2000-01-01T00:00:00Z")); + $exp = new JsonResponse([ + 'api_version' => API::LEVEL, + 'auth' => 1, + 'last_refreshed_on_time' => 946684800, + ]); + $act = $this->h->dispatch($this->req("api")); + $this->assertMessage($exp, $act); + \Phake::when(Arsse::$db)->subscriptionRefreshed(Arsse::$user->id)->thenReturn(null); // no subscriptions + $exp = new JsonResponse([ + 'api_version' => API::LEVEL, + 'auth' => 1, + 'last_refreshed_on_time' => null, + ]); + $act = $this->h->dispatch($this->req("api")); + $this->assertMessage($exp, $act); + \Phake::when($this->h)->logIn->thenReturn(false); + $exp = new JsonResponse([ + 'api_version' => API::LEVEL, + 'auth' => 0, + ]); + $act = $this->h->dispatch($this->req("api")); + $this->assertMessage($exp, $act); + } } From 2d18be959cc8b55f3dedd76202fdbdb029f1c67e Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 10 Apr 2019 18:27:57 -0400 Subject: [PATCH 104/142] Tests for undoing read marks --- lib/REST/Fever/API.php | 2 +- tests/cases/REST/Fever/TestAPI.php | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index baa501f..643d1e8 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -302,7 +302,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // may not actually signify a mark, but we'll otherwise also count back fifteen seconds $c = new Context; $lastUnread = Date::normalize($lastUnread, "sql"); - $since = Date::sub("DT15S", $lastUnread); + $since = Date::sub("PT15S", $lastUnread); $c->unread(false)->markedSince($since); Arsse::$db->articleMark(Arsse::$user->id, ['read' => false], $c); } diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index aa7bc75..4023122 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -467,4 +467,20 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { $act = $this->h->dispatch($this->req("api")); $this->assertMessage($exp, $act); } + + public function testUndoReadMarks() { + $unread = [['id' => 4],['id' => 5],['id' => 6]]; + $out = ['unread_item_ids' => "4,5,6"]; + \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->limit(1), ["marked_date"], ["marked_date desc"])->thenReturn(new Result([['marked_date' => "2000-01-01 00:00:00"]])); + \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->unread(true))->thenReturn(new Result($unread)); + \Phake::when(Arsse::$db)->articleMark->thenReturn(0); + $exp = new JsonResponse($out); + $act = $this->h->dispatch($this->req("api", ['unread_recently_read' => 1])); + $this->assertMessage($exp, $act); + \Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, ['read' => false], (new Context)->unread(false)->markedSince("1999-12-31T23:59:45Z")); + \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->limit(1), ["marked_date"], ["marked_date desc"])->thenReturn(new Result([])); + $act = $this->h->dispatch($this->req("api", ['unread_recently_read' => 1])); + $this->assertMessage($exp, $act); + \Phake::verify(Arsse::$db)->articleMark; // only called one time, above + } } From 825c286e5b5551ab8a2a2276955b95a7e8a27958 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 19 Apr 2019 18:01:31 -0400 Subject: [PATCH 105/142] Prototype OPML import parser --- lib/ImportExport/OPML.php | 53 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php index 9b70a4a..06ebfb3 100644 --- a/lib/ImportExport/OPML.php +++ b/lib/ImportExport/OPML.php @@ -10,6 +10,59 @@ use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\User\Exception as UserException; class OPML { + public function import(string $user, string $opml, bool $flat = false, bool $replace = false): bool { + list($folders, $feeds) = $this->parse($opml, $flat); + return true; + } + + protected function parse(string $opml, bool $flat): array { + $d = new \DOMDocument; + if (!@$d->loadXML($opml)) { + // not a valid XML document + throw new \Exception; + } + $body = $d->getElementsByTagName("body"); + if ($d->documentElement->nodeName !== "opml" || !$body->length || $body->item(0)->parentNode != $d->documentElement) { + // not a valid OPML document + throw new \Exception; + } + $body = $body->item(0); + $folders = []; + $feeds = []; + $folderMap = new \SplObjectStorage; + $folderMap[$body] = sizeof($folderMap); + $node = $body->firstChild; + while ($node && $node != $body) { + if ($node->nodeType == \XML_ELEMENT_NODE && $node->nodeName === "outline") { + if ($node->getAttribute("type") === "rss") { + $url = $node->getAttribute("xmlUrl"); + if (strlen($url)) { + $title = $node->getAttribute("text"); + $folder = $folderMap[$node->parentNode] ?? 0; + $categories = $node->getAttribute("category"); + if (strlen($categories)) { + $categories = array_map(function($v) { + return trim(preg_replace("/\s+/g", " ", $v)); + }, explode(",", $categories)); + } + $feeds[] = ['url' => $url, 'title' => $title, 'folder' => $folder, 'categories' => $categories]; + } + $node = $node->nextSibling ?: $node->parentNode; + } else { + if (!$flat) { + $id = sizeof($folderMap); + $folderMap[$node] = $id; + $folders[$id] = ['id' => $id, 'name' => $node->getAttribute("text"), 'parent' => $folderMap[$node->parentNode]]; + } + $node = $node->hasChildNodes() ? $node->firstChild : ($node->nextSibling ?: $node->parentNode); + } + } else { + $node = $node->nextSibling ?: $node->parentNode; + } + } + return [$feeds, $folders]; + } + public function export(string $user, bool $flat = false): string { if (!Arsse::$user->exists($user)) { throw new UserException("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); From ceecd583937dd7c7b4016ab7b77bbc7b09db8a41 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sun, 21 Apr 2019 13:10:47 -0400 Subject: [PATCH 106/142] OPML parsing comments and minr fixes --- lib/Database.php | 2 +- lib/ImportExport/OPML.php | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 2486907..df29fb4 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -700,7 +700,7 @@ class Database { * @param string $url The URL of the newsfeed or discovery source * @param string $fetchUser The user name required to access the newsfeed, if applicable * @param string $fetchPassword The password required to fetch the newsfeed, if applicable; this will be stored in cleartext - * @param boolean $discovery Whether to perform newsfeed discovery if $url points to an HTML document + * @param boolean $discover Whether to perform newsfeed discovery if $url points to an HTML document */ public function subscriptionAdd(string $user, string $url, string $fetchUser = "", string $fetchPassword = "", bool $discover = true): int { if (!Arsse::$user->authorize($user, __FUNCTION__)) { diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php index 06ebfb3..5a1da74 100644 --- a/lib/ImportExport/OPML.php +++ b/lib/ImportExport/OPML.php @@ -11,7 +11,8 @@ use JKingWeb\Arsse\User\Exception as UserException; class OPML { public function import(string $user, string $opml, bool $flat = false, bool $replace = false): bool { - list($folders, $feeds) = $this->parse($opml, $flat); + list($feeds, $folders) = $this->parse($opml, $flat); + return true; } @@ -29,34 +30,47 @@ class OPML { $body = $body->item(0); $folders = []; $feeds = []; + // add the root folder to a map from folder DOM nodes to folder ID numbers $folderMap = new \SplObjectStorage; $folderMap[$body] = sizeof($folderMap); + // iterate through each node in the body $node = $body->firstChild; while ($node && $node != $body) { if ($node->nodeType == \XML_ELEMENT_NODE && $node->nodeName === "outline") { + // process any nodes which are outlines if ($node->getAttribute("type") === "rss") { + // feed nodes $url = $node->getAttribute("xmlUrl"); if (strlen($url)) { + // only process the node if it has a URL $title = $node->getAttribute("text"); $folder = $folderMap[$node->parentNode] ?? 0; $categories = $node->getAttribute("category"); if (strlen($categories)) { + // collapse and trim whitespace from category names, if any, splitting along commas $categories = array_map(function($v) { return trim(preg_replace("/\s+/g", " ", $v)); }, explode(",", $categories)); + } else { + $categories = []; } $feeds[] = ['url' => $url, 'title' => $title, 'folder' => $folder, 'categories' => $categories]; } + // skip any child nodes of a feed outline-entry $node = $node->nextSibling ?: $node->parentNode; } else { + // any outline entries which are not feeds are treated as folders if (!$flat) { + // only process folders if we're not treating he file as flat $id = sizeof($folderMap); $folderMap[$node] = $id; $folders[$id] = ['id' => $id, 'name' => $node->getAttribute("text"), 'parent' => $folderMap[$node->parentNode]]; } + // proceed to child nodes, if any $node = $node->hasChildNodes() ? $node->firstChild : ($node->nextSibling ?: $node->parentNode); } } else { + // skip any node which is not an outline element; if the node has descendents they are skipped as well $node = $node->nextSibling ?: $node->parentNode; } } From 2af223753d0c0493883f31b491b83532928fd0d8 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sun, 21 Apr 2019 14:07:36 -0400 Subject: [PATCH 107/142] Function to add a feed without a subscription --- lib/Database.php | 55 ++++++++++++++++++++++++++++++------------------ 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index df29fb4..43e6a2e 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -706,26 +706,8 @@ class Database { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } - // check to see if the feed exists - $check = $this->db->prepare("SELECT id from arsse_feeds where url = ? and username = ? and password = ?", "str", "str", "str"); - $feedID = $check->run($url, $fetchUser, $fetchPassword)->getValue(); - if ($discover && is_null($feedID)) { - // if the feed doesn't exist, first perform discovery if requested and check for the existence of that URL - $url = Feed::discover($url, $fetchUser, $fetchPassword); - $feedID = $check->run($url, $fetchUser, $fetchPassword)->getValue(); - } - if (is_null($feedID)) { - // if the feed still doesn't exist in the database, add it to the database; we do this unconditionally so as to lock SQLite databases for as little time as possible - $feedID = $this->db->prepare('INSERT INTO arsse_feeds(url,username,password) values(?,?,?)', 'str', 'str', 'str')->run($url, $fetchUser, $fetchPassword)->lastId(); - try { - // perform an initial update on the newly added feed - $this->feedUpdate($feedID, true); - } catch (\Throwable $e) { - // if the update fails, delete the feed we just added - $this->db->prepare('DELETE from arsse_feeds where id = ?', 'int')->run($feedID); - throw $e; - } - } + // get the ID of the underlying feed, or add it if it's not yet in the database + $feedID = $this->feedAdd($url, $fetchUser, $fetchPassword, $discover); // Add the feed to the user's subscriptions and return the new subscription's ID. return $this->db->prepare('INSERT INTO arsse_subscriptions(owner,feed) values(?,?)', 'str', 'int')->run($user, $feedID)->lastId(); } @@ -983,6 +965,39 @@ class Database { return $out; } + /** Adds a newsfeed to the database without adding any subscriptions, and returns the numeric identifier of the added feed + * + * If the feed already exists in the database, the existing ID is returned + * + * @param string $url The URL of the newsfeed or discovery source + * @param string $fetchUser The user name required to access the newsfeed, if applicable + * @param string $fetchPassword The password required to fetch the newsfeed, if applicable; this will be stored in cleartext + * @param boolean $discover Whether to perform newsfeed discovery if $url points to an HTML document + */ + public function feedAdd(string $url, string $fetchUser = "", string $fetchPassword = "", bool $discover = true): int { + // check to see if the feed already exists + $check = $this->db->prepare("SELECT id from arsse_feeds where url = ? and username = ? and password = ?", "str", "str", "str"); + $feedID = $check->run($url, $fetchUser, $fetchPassword)->getValue(); + if ($discover && is_null($feedID)) { + // if the feed doesn't exist, first perform discovery if requested and check for the existence of that URL + $url = Feed::discover($url, $fetchUser, $fetchPassword); + $feedID = $check->run($url, $fetchUser, $fetchPassword)->getValue(); + } + if (is_null($feedID)) { + // if the feed still doesn't exist in the database, add it to the database; we do this unconditionally so as to lock SQLite databases for as little time as possible + $feedID = $this->db->prepare('INSERT INTO arsse_feeds(url,username,password) values(?,?,?)', 'str', 'str', 'str')->run($url, $fetchUser, $fetchPassword)->lastId(); + try { + // perform an initial update on the newly added feed + $this->feedUpdate($feedID, true); + } catch (\Throwable $e) { + // if the update fails, delete the feed we just added + $this->db->prepare('DELETE from arsse_feeds where id = ?', 'int')->run($feedID); + throw $e; + } + } + return (int) $feedID; + } + /** Returns an indexed array of numeric identifiers for newsfeeds which should be refreshed */ public function feedListStale(): array { $feeds = $this->db->query("SELECT id from arsse_feeds where next_fetch <= CURRENT_TIMESTAMP")->getAll(); From 3899ee6b4ef2d0a0c3d5c83723a330a50964b5e6 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 27 Apr 2019 18:32:15 -0400 Subject: [PATCH 108/142] Allow for replacing label and tag associations This supplements adding and removing --- lib/Database.php | 141 +++++++++++++++-------- lib/REST/TinyTinyRSS/API.php | 3 +- tests/cases/Database/SeriesLabel.php | 37 +++++- tests/cases/Database/SeriesTag.php | 45 ++++++-- tests/cases/REST/TinyTinyRSS/TestAPI.php | 16 +-- 5 files changed, 173 insertions(+), 69 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 43e6a2e..72a06e4 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -43,6 +43,12 @@ class Database { const LIMIT_SET_SIZE = 25; /** The length of a string in an embedded set beyond which a parameter placeholder will be used for the string */ const LIMIT_SET_STRING_LENGTH = 200; + /** Makes tag/label association change operations remove members */ + const ASSOC_REMOVE = 0; + /** Makes tag/label association change operations add members */ + const ASSOC_ADD = 1; + /** Makes tag/label association change operations replace members */ + const ASSOC_REPLACE = 2; /** A map database driver short-names and their associated class names */ const DRIVER_NAMES = [ 'sqlite3' => \JKingWeb\Arsse\Db\SQLite3\Driver::class, @@ -1955,37 +1961,61 @@ class Database { * @param string $user The owner of the label * @param integer|string $id The numeric identifier or name of the label * @param Context $context The query context matching the desired articles - * @param boolean $remove Whether to remove (true) rather than add (true) an association with the articles matching the context + * @param int $mode Whether to add (ASSOC_ADD), remove (ASSOC_REMOVE), or replace with (ASSOC_REPLACE) the matching associations * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) */ - public function labelArticlesSet(string $user, $id, Context $context = null, bool $remove = false, bool $byName = false): int { + public function labelArticlesSet(string $user, $id, Context $context, int $mode = self::ASSOC_ADD, bool $byName = false): int { + if (!in_array($mode, [self::ASSOC_ADD, self::ASSOC_REMOVE, self::ASSOC_REPLACE])) { + throw new Exception("constantUnknown", $mode); // @codeCoverageIgnore + } if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } - // validate the label ID, and get the numeric ID if matching by name + // validate the tag ID, and get the numeric ID if matching by name $id = $this->labelValidateId($user, $id, $byName, true)['id']; - $context = $context ?? new Context; - // prepare either one or two queries - // first update any existing entries with the removal or re-addition of their association - $q1 = $this->articleQuery($user, $context); - $q1->pushCTE("target_articles"); - $q1->setBody("UPDATE arsse_label_members set assigned = ?, modified = CURRENT_TIMESTAMP where label = ? and assigned <> ? and article in (select id from target_articles)", ["bool","int","bool"], [!$remove, $id, !$remove]); - $v1 = $q1->getValues(); - $q1 = $this->db->prepare($q1->getQuery(), $q1->getTypes()); - // next, if we're not removing, add any new entries that need to be added - if (!$remove) { - $q2 = $this->articleQuery($user, $context, ["id", "subscription"]); - $q2->pushCTE("target_articles"); - $q2->setBody("SELECT ?,id,subscription from target_articles where id not in (select article from arsse_label_members where label = ?)", ["int", "int"], [$id, $id]); - $v2 = $q2->getValues(); - $q2 = $this->db->prepare("INSERT INTO arsse_label_members(label,article,subscription) ".$q2->getQuery(), $q2->getTypes()); + // get the list of articles matching the context + $articles = iterator_to_array($this->articleList($user, $context ?? new Context)); + // an empty article list is a special case + if (!sizeof($articles)) { + if ($mode == self::ASSOC_REPLACE) { + // replacing with an empty set means setting everything to zero + return $this->db->prepare("UPDATE arsse_label_members set assigned = 0, modified = CURRENT_TIMESTAMP where label = ? and assigned = 1", "int")->run($id)->changes(); + } else { + // adding or removing is a no-op + return 0; + } + } else { + $articles = array_column($articles, "id"); + } + // prepare up to three queries: removing requires one, adding two, and replacing three + list($inClause, $inTypes, $inValues) = $this->generateIn($articles, "int"); + $updateQ = "UPDATE arsse_label_members set assigned = ?, modified = CURRENT_TIMESTAMP where label = ? and assigned <> ? and article %in% ($inClause)"; + $updateT = ["bool", "int", "bool", $inTypes]; + $insertQ = "INSERT INTO arsse_label_members(label,article,subscription) SELECT ?,a.id,s.id from arsse_articles as a join arsse_subscriptions as s on a.feed = s.feed where s.owner = ? and a.id not in (select article from arsse_label_members where label = ?) and a.id in ($inClause)"; + $insertT = ["int", "str", "int", $inTypes]; + $clearQ = str_replace("%in%", "not in", $updateQ); + $clearT = $updateT; + $updateQ = str_replace("%in%", "in", $updateQ); + $qList = []; + switch ($mode) { + case self::ASSOC_REMOVE: + $qList[] = [$updateQ, $updateT, [false, $id, false, $inValues]]; // soft-delete any existing associations + break; + case self::ASSOC_ADD: + $qList[] = [$updateQ, $updateT, [true, $id, true, $inValues]]; // re-enable any previously soft-deleted association + $qList[] = [$insertQ, $insertT, [$id, $user, $id, $inValues]]; // insert any newly-required associations + break; + case self::ASSOC_REPLACE: + $qList[] = [$clearQ, $clearT, [false, $id, false, $inValues]]; // soft-delete any existing associations for articles not in the list + $qList[] = [$updateQ, $updateT, [true, $id, true, $inValues]]; // re-enable any previously soft-deleted association + $qList[] = [$insertQ, $insertT, [$id, $user, $id, $inValues]]; // insert any newly-required associations + break; } // execute them in a transaction $out = 0; $tr = $this->begin(); - $out += $q1->run($v1)->changes(); - if (!$remove) { - $out += $q2->run($v2)->changes(); + foreach ($qList as list($q, $t, $v)) { + $out += $this->db->prepare($q, ...$t)->run(...$v)->changes(); } $tr->commit(); return $out; @@ -2235,47 +2265,58 @@ class Database { * * @param string $user The owner of the tag * @param integer|string $id The numeric identifier or name of the tag - * @param integer[] $context The query context matching the desired subscriptions - * @param boolean $remove Whether to remove (true) rather than add (true) an association with the subscriptions matching the context + * @param integer[] $subscriptions An array listing the desired subscriptions + * @param int $mode Whether to add (ASSOC_ADD), remove (ASSOC_REMOVE), or replace with (ASSOC_REPLACE) the listed associations * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) */ - public function tagSubscriptionsSet(string $user, $id, array $subscriptions, bool $remove = false, bool $byName = false): int { + public function tagSubscriptionsSet(string $user, $id, array $subscriptions, int $mode = self::ASSOC_ADD, bool $byName = false): int { + if (!in_array($mode, [self::ASSOC_ADD, self::ASSOC_REMOVE, self::ASSOC_REPLACE])) { + throw new Exception("constantUnknown", $mode); // @codeCoverageIgnore + } if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } // validate the tag ID, and get the numeric ID if matching by name $id = $this->tagValidateId($user, $id, $byName, true)['id']; - // prepare either one or two queries + // an empty subscription list is a special case + if (!sizeof($subscriptions)) { + if ($mode == self::ASSOC_REPLACE) { + // replacing with an empty set means setting everything to zero + return $this->db->prepare("UPDATE arsse_tag_members set assigned = 0, modified = CURRENT_TIMESTAMP where tag = ? and assigned = 1", "int")->run($id)->changes(); + } else { + // adding or removing is a no-op + return 0; + } + } + // prepare up to three queries: removing requires one, adding two, and replacing three list($inClause, $inTypes, $inValues) = $this->generateIn($subscriptions, "int"); - // first update any existing entries with the removal or re-addition of their association - $q1 = $this->db->prepare( - "UPDATE arsse_tag_members - set assigned = ?, modified = CURRENT_TIMESTAMP - where tag = ? and assigned <> ? and subscription in (select id from arsse_subscriptions where owner = ? and id in ($inClause))", - "bool", - "int", - "bool", - "str", - $inTypes - ); - $v1 = [!$remove, $id, !$remove, $user, $inValues]; - // next, if we're not removing, add any new entries that need to be added - if (!$remove) { - $q2 = $this->db->prepare( - "INSERT INTO arsse_tag_members(tag,subscription) SELECT ?,id from arsse_subscriptions where id not in (select subscription from arsse_tag_members where tag = ?) and owner = ? and id in ($inClause)", - "int", - "int", - "str", - $inTypes - ); - $v2 = [$id, $id, $user, $inValues]; + $updateQ = "UPDATE arsse_tag_members set assigned = ?, modified = CURRENT_TIMESTAMP where tag = ? and assigned <> ? and subscription in (select id from arsse_subscriptions where owner = ? and id %in% ($inClause))"; + $updateT = ["bool", "int", "bool", "str", $inTypes]; + $insertQ = "INSERT INTO arsse_tag_members(tag,subscription) SELECT ?,id from arsse_subscriptions where id not in (select subscription from arsse_tag_members where tag = ?) and owner = ? and id in ($inClause)"; + $insertT = ["int", "int", "str", $inTypes]; + $clearQ = str_replace("%in%", "not in", $updateQ); + $clearT = $updateT; + $updateQ = str_replace("%in%", "in", $updateQ); + $qList = []; + switch ($mode) { + case self::ASSOC_REMOVE: + $qList[] = [$updateQ, $updateT, [0, $id, 0, $user, $inValues]]; // soft-delete any existing associations + break; + case self::ASSOC_ADD: + $qList[] = [$updateQ, $updateT, [1, $id, 1, $user, $inValues]]; // re-enable any previously soft-deleted association + $qList[] = [$insertQ, $insertT, [$id, $id, $user, $inValues]]; // insert any newly-required associations + break; + case self::ASSOC_REPLACE: + $qList[] = [$clearQ, $clearT, [0, $id, 0, $user, $inValues]]; // soft-delete any existing associations for subscriptions not in the list + $qList[] = [$updateQ, $updateT, [1, $id, 1, $user, $inValues]]; // re-enable any previously soft-deleted association + $qList[] = [$insertQ, $insertT, [$id, $id, $user, $inValues]]; // insert any newly-required associations + break; } // execute them in a transaction $out = 0; $tr = $this->begin(); - $out += $q1->run($v1)->changes(); - if (!$remove) { - $out += $q2->run($v2)->changes(); + foreach ($qList as list($q, $t, $v)) { + $out += $this->db->prepare($q, ...$t)->run(...$v)->changes(); } $tr->commit(); return $out; diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 8bf85bc..d29c0cf 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -1017,6 +1017,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $label = $this->labelIn($data['label_id']); $articles = explode(",", (string) $data['article_ids']); $assign = $data['assign'] ?? false; + $assign = $assign ? Database::ASSOC_ADD : Database::ASSOC_REMOVE; $out = 0; $in = array_chunk($articles, 50); for ($a = 0; $a < sizeof($in); $a++) { @@ -1024,7 +1025,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $c = new Context; $c->articles($in[$a]); try { - $out += Arsse::$db->labelArticlesSet(Arsse::$user->id, $label, $c, !$assign); + $out += Arsse::$db->labelArticlesSet(Arsse::$user->id, $label, $c, $assign); } catch (ExceptionInput $e) { } } diff --git a/tests/cases/Database/SeriesLabel.php b/tests/cases/Database/SeriesLabel.php index 9ffc01b..1f11004 100644 --- a/tests/cases/Database/SeriesLabel.php +++ b/tests/cases/Database/SeriesLabel.php @@ -7,6 +7,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\Database; use JKingWeb\Arsse\Arsse; +use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Misc\Date; use Phake; @@ -490,14 +491,14 @@ trait SeriesLabel { } public function testClearALabelFromArticles() { - Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([1,5]), true); + Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([1,5]), Database::ASSOC_REMOVE); $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_label_members']['rows'][0][3] = 0; $this->compareExpectations($state); } public function testApplyALabelToArticlesByName() { - Arsse::$db->labelArticlesSet("john.doe@example.com", "Interesting", (new Context)->articles([2,5]), false, true); + Arsse::$db->labelArticlesSet("john.doe@example.com", "Interesting", (new Context)->articles([2,5]), Database::ASSOC_ADD, true); $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_label_members']['rows'][4][3] = 1; $state['arsse_label_members']['rows'][] = [1,2,1,1]; @@ -505,12 +506,42 @@ trait SeriesLabel { } public function testClearALabelFromArticlesByName() { - Arsse::$db->labelArticlesSet("john.doe@example.com", "Interesting", (new Context)->articles([1,5]), true, true); + Arsse::$db->labelArticlesSet("john.doe@example.com", "Interesting", (new Context)->articles([1,5]), Database::ASSOC_REMOVE, true); $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_label_members']['rows'][0][3] = 0; $this->compareExpectations($state); } + public function testApplyALabelToNoArticles() { + Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([10000])); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $this->compareExpectations($state); + } + + public function testClearALabelFromNoArticles() { + Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([10000]), Database::ASSOC_REMOVE); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $this->compareExpectations($state); + } + + public function testReplaceArticlesOfALabel() { + Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([2,5]), Database::ASSOC_REPLACE); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $state['arsse_label_members']['rows'][0][3] = 0; + $state['arsse_label_members']['rows'][2][3] = 0; + $state['arsse_label_members']['rows'][4][3] = 1; + $state['arsse_label_members']['rows'][] = [1,2,1,1]; + $this->compareExpectations($state); + } + + public function testPurgeArticlesOfALabel() { + Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([10000]), Database::ASSOC_REPLACE); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $state['arsse_label_members']['rows'][0][3] = 0; + $state['arsse_label_members']['rows'][2][3] = 0; + $this->compareExpectations($state); + } + public function testApplyALabelToArticlesWithoutAuthority() { Phake::when(Arsse::$user)->authorize->thenReturn(false); $this->assertException("notAuthorized", "User", "ExceptionAuthz"); diff --git a/tests/cases/Database/SeriesTag.php b/tests/cases/Database/SeriesTag.php index 404e2f1..b3ff4e4 100644 --- a/tests/cases/Database/SeriesTag.php +++ b/tests/cases/Database/SeriesTag.php @@ -7,6 +7,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\Database; use JKingWeb\Arsse\Arsse; +use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Misc\Date; use Phake; @@ -311,7 +312,7 @@ trait SeriesTag { Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, ['name' => "Exciting"]); } - public function testListTagledSubscriptions() { + public function testListTaggedSubscriptions() { $exp = [1,5]; $this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 1)); $this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", "Interesting", true)); @@ -323,17 +324,17 @@ trait SeriesTag { $this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", "Lonely", true)); } - public function testListTagledSubscriptionsForAMissingTag() { + public function testListTaggedSubscriptionsForAMissingTag() { $this->assertException("subjectMissing", "Db", "ExceptionInput"); Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 3); } - public function testListTagledSubscriptionsForAnInvalidTag() { + public function testListTaggedSubscriptionsForAnInvalidTag() { $this->assertException("typeViolation", "Db", "ExceptionInput"); Arsse::$db->tagSubscriptionsGet("john.doe@example.com", -1); } - public function testListTagledSubscriptionsWithoutAuthority() { + public function testListTaggedSubscriptionsWithoutAuthority() { Phake::when(Arsse::$user)->authorize->thenReturn(false); $this->assertException("notAuthorized", "User", "ExceptionAuthz"); Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 1); @@ -348,14 +349,14 @@ trait SeriesTag { } public function testClearATagFromSubscriptions() { - Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [1,3], true); + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [1,3], Database::ASSOC_REMOVE); $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_tag_members']['rows'][0][2] = 0; $this->compareExpectations($state); } public function testApplyATagToSubscriptionsByName() { - Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [3,4], false, true); + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [3,4], Database::ASSOC_ADD, true); $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_tag_members']['rows'][1][2] = 1; $state['arsse_tag_members']['rows'][] = [1,4,1]; @@ -363,12 +364,42 @@ trait SeriesTag { } public function testClearATagFromSubscriptionsByName() { - Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [1,3], true, true); + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [1,3], Database::ASSOC_REMOVE, true); $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_tag_members']['rows'][0][2] = 0; $this->compareExpectations($state); } + public function testApplyATagToNoSubscriptionsByName() { + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [], Database::ASSOC_ADD, true); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $this->compareExpectations($state); + } + + public function testClearATagFromNoSubscriptionsByName() { + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [], Database::ASSOC_REMOVE, true); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $this->compareExpectations($state); + } + + public function testReplaceSubscriptionsOfATag() { + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [3,4], Database::ASSOC_REPLACE); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $state['arsse_tag_members']['rows'][0][2] = 0; + $state['arsse_tag_members']['rows'][1][2] = 1; + $state['arsse_tag_members']['rows'][2][2] = 0; + $state['arsse_tag_members']['rows'][] = [1,4,1]; + $this->compareExpectations($state); + } + + public function testPurgeSubscriptionsOfATag() { + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [], Database::ASSOC_REPLACE); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $state['arsse_tag_members']['rows'][0][2] = 0; + $state['arsse_tag_members']['rows'][2][2] = 0; + $this->compareExpectations($state); + } + public function testApplyATagToSubscriptionsWithoutAuthority() { Phake::when(Arsse::$user)->authorize->thenReturn(false); $this->assertException("notAuthorized", "User", "ExceptionAuthz"); diff --git a/tests/cases/REST/TinyTinyRSS/TestAPI.php b/tests/cases/REST/TinyTinyRSS/TestAPI.php index 91b370c..5cab996 100644 --- a/tests/cases/REST/TinyTinyRSS/TestAPI.php +++ b/tests/cases/REST/TinyTinyRSS/TestAPI.php @@ -1289,18 +1289,18 @@ LONG_STRING; ]; Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, $this->anything(), (new Context)->articles([]), $this->anything())->thenThrow(new ExceptionInput("tooShort")); // data model function requires one valid integer for multiples Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, $this->anything(), (new Context)->articles($list[0]), $this->anything())->thenThrow(new ExceptionInput("tooLong")); // data model function limited to 50 items for multiples - Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), true)->thenReturn(42); - Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), true)->thenReturn(47); - Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), false)->thenReturn(5); - Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), false)->thenReturn(2); + Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), Database::ASSOC_REMOVE)->thenReturn(42); + Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), Database::ASSOC_REMOVE)->thenReturn(47); + Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), Database::ASSOC_ADD)->thenReturn(5); + Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), Database::ASSOC_ADD)->thenReturn(2); $exp = $this->respGood(['status' => "OK", 'updated' => 89]); $this->assertMessage($exp, $this->req($in[0])); - Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), true); - Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), true); + Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), Database::ASSOC_REMOVE); + Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), Database::ASSOC_REMOVE); $exp = $this->respGood(['status' => "OK", 'updated' => 7]); $this->assertMessage($exp, $this->req($in[1])); - Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), false); - Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), false); + Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), Database::ASSOC_ADD); + Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), Database::ASSOC_ADD); $exp = $this->respGood(['status' => "OK", 'updated' => 0]); $this->assertMessage($exp, $this->req($in[2])); $exp = $this->respErr("INCORRECT_USAGE"); From 67492cd7ef888e31bc3dc2b12027dec6011248a6 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 27 Apr 2019 19:50:03 -0400 Subject: [PATCH 109/142] Prototype OPML importer routine In theory the import (as opposed to parse) routine could be used for any format; this could be used to implement an ad hoc JSON format to avoid the loss of commas in tags with OPML --- lib/ImportExport/OPML.php | 149 ++++++++++++++++++++++++++++++++++---- 1 file changed, 134 insertions(+), 15 deletions(-) diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php index 5a1da74..616fa82 100644 --- a/lib/ImportExport/OPML.php +++ b/lib/ImportExport/OPML.php @@ -7,12 +7,134 @@ declare(strict_types=1); namespace JKingWeb\Arsse\ImportExport; use JKingWeb\Arsse\Arsse; +use JKingWeb\Arsse\Database; +use JKingWeb\Arsse\Db\ExceptionInput as InputException; use JKingWeb\Arsse\User\Exception as UserException; class OPML { public function import(string $user, string $opml, bool $flat = false, bool $replace = false): bool { + // first extract useful information from the input list($feeds, $folders) = $this->parse($opml, $flat); - + $folderMap = []; + foreach ($folders as $f) { + // check to make sure folder names are all valid + if (!strlen(trim($f['name']))) { + throw new \Exception; + } + // check for duplicates + if (!isset($folderMap[$f['parent']])) { + $folderMap[$f['parent']] = []; + } + if (isset($folderMap[$f['parent']][$f['name']])) { + throw new \Exception; + } else { + $folderMap[$f['parent']][$f['name']] = true; + } + } + // get feed IDs for each URL, adding feeds where necessary + foreach ($feeds as $k => $f) { + $feeds[$k]['id'] = Arsse::$db->feedAdd(($f['url'])); + } + // start a transaction for atomic rollback + $tr = Arsse::$db->begin(); + // get current state of database + $foldersDb = iterator_to_array(Arsse::$db->folderList(Arsse::$user->id)); + $feedsDb = iterator_to_array(Arsse::$db->subscriptionList(Arsse::$user->id)); + $tagsDb = iterator_to_array(Arsse::$db->tagList(Arsse::$user->id)); + // reconcile folders + $folderMap = [0 => 0]; + foreach ($folders as $id => $f) { + $parent = $folderMap[$f['parent']]; + // find a match for the import folder in the existing folders + foreach ($foldersDb as $db) { + if ((int) $db['parent'] == $parent && $db['name'] === $f['name']) { + $folderMap[$id] = (int) $db['id']; + break; + } + } + if (!isset($folderMap[$id])) { + // if no existing folder exists, add one + $folderMap[$id] = Arsse::$db->folderAdd(Arsse::$user->id, ['name' => $f['name'], 'parent' -> $parent]); + } + } + // process newsfeed subscriptions + $feedMap = []; + $tagMap = []; + foreach ($feeds as $f) { + $folder = $folderMap[$f['folder']]; + $title = strlen(trim($f['title'])) ? $f['title'] : null; + $found = false; + // find a match for the import feed is existing subscriptions + foreach ($feedsDb as $db) { + if ((int) $db['feed'] == $f['id']) { + $found = true; + $feedMap[$f['id']] = (int) $db['id']; + break; + } + } + if (!$found) { + // if no subscription exists, add one + $feedMap[$f['id']] = Arsse::$db->subscriptionAdd(Arsse::$user->id, $f['url']); + } + if (!$found || $replace) { + // set the subscription's properties, if this is a new feed or we're doing a full replacement + Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $feedMap[$f['id']], ['title' => $title, 'folder' => $folder]); + // compile the set of used tags, if this is a new feed or we're doing a full replacement + foreach ($f['tags'] as $t) { + if (!strlen(trim($t))) { + // ignore any blank tags + continue; + } + if (!isset($tagMap[$t])) { + // populate the tag map + $tagMap[$t] = []; + } + $tagMap[$t][] = $f['id']; + } + } + } + // set tags + $mode = $replace ? Database::ASSOC_REPLACE : Database::ASSOC_ADD; + foreach ($tagMap as $tag => $subs) { + // make sure the tag exists + $found = false; + foreach ($tagsDb as $db) { + if ($tag === $db['name']) { + $found = true; + break; + } + } + if (!$found) { + // add the tag if it wasn't found + Arsse::$db->tagAdd(Arsse::$user->id, ['name' => $tag]); + } + Arsse::$db->tagSubscriptionsSet(Arsse::$user->id, $tag, $subs, $mode, true); + } + // finally, if we're performing a replacement, delete any subscriptions, folders, or tags which were not present in the import + if ($replace) { + foreach (array_diff(array_column($feedsDb, "id"), $feedMap) as $id) { + try { + Arsse::$db->subscriptionRemove(Arsse::$user->id, $id); + } catch (InputException $e) { + // ignore errors + } + } + foreach (array_diff(array_column($foldersDb, "id"), $folderMap) as $id) { + try { + Arsse::$db->folderRemove(Arsse::$user->id, $id); + } catch (InputException $e) { + // ignore errors + } + } + foreach (array_diff(array_column($tagsDb, "name"), array_keys($tagMap)) as $id) { + try { + Arsse::$db->tagRemove(Arsse::$user->id, $id, true); + } catch (InputException $e) { + // ignore errors + } + } + } + $tr->commit(); return true; } @@ -41,21 +163,18 @@ class OPML { if ($node->getAttribute("type") === "rss") { // feed nodes $url = $node->getAttribute("xmlUrl"); - if (strlen($url)) { - // only process the node if it has a URL - $title = $node->getAttribute("text"); - $folder = $folderMap[$node->parentNode] ?? 0; - $categories = $node->getAttribute("category"); - if (strlen($categories)) { - // collapse and trim whitespace from category names, if any, splitting along commas - $categories = array_map(function($v) { - return trim(preg_replace("/\s+/g", " ", $v)); - }, explode(",", $categories)); - } else { - $categories = []; - } - $feeds[] = ['url' => $url, 'title' => $title, 'folder' => $folder, 'categories' => $categories]; + $title = $node->getAttribute("text"); + $folder = $folderMap[$node->parentNode] ?? 0; + $categories = $node->getAttribute("category"); + if (strlen($categories)) { + // collapse and trim whitespace from category names, if any, splitting along commas + $categories = array_map(function($v) { + return trim(preg_replace("/\s+/g", " ", $v)); + }, explode(",", $categories)); + } else { + $categories = []; } + $feeds[] = ['url' => $url, 'title' => $title, 'folder' => $folder, 'tags' => $categories]; // skip any child nodes of a feed outline-entry $node = $node->nextSibling ?: $node->parentNode; } else { From b9821d925afe3d4350adbe479c806d95f48cdf7a Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 1 May 2019 10:46:44 -0400 Subject: [PATCH 110/142] CLI for OPML import, and proper exceptions --- lib/AbstractException.php | 6 ++++++ lib/CLI.php | 11 +++++++++-- lib/ImportExport/OPML.php | 19 +++++++++++++++---- locale/en.php | 18 ++++++++---------- 4 files changed, 38 insertions(+), 16 deletions(-) diff --git a/lib/AbstractException.php b/lib/AbstractException.php index e4f22a9..1e3a2dd 100644 --- a/lib/AbstractException.php +++ b/lib/AbstractException.php @@ -86,8 +86,14 @@ abstract class AbstractException extends \Exception { "Feed/Exception.xmlEntity" => 10512, "Feed/Exception.subscriptionNotFound" => 10521, "Feed/Exception.unsupportedFeedFormat" => 10522, + "ImportExport/Exception.fileMissing" => 10601, + "ImportExport/Exception.fileUnreadable" => 10603, "ImportExport/Exception.fileUnwritable" => 10604, "ImportExport/Exception.fileUncreatable" => 10605, + "ImportExport/Exception.invalidSyntax" => 10611, + "ImportExport/Exception.invalidSemantics" => 10612, + "ImportExport/Exception.invalidFolderName" => 10613, + "ImportExport/Exception.invalidFolderCopy" => 10614, ]; public function __construct(string $msgID = "", $vars = null, \Throwable $e = null) { diff --git a/lib/CLI.php b/lib/CLI.php index 218e3d3..e899896 100644 --- a/lib/CLI.php +++ b/lib/CLI.php @@ -24,7 +24,10 @@ Usage: arsse.php user unset-pass [--oldpass=] [--fever] arsse.php user auth [--fever] - arsse.php export [] [-f | --flat] + arsse.php export [] + [-f | --flat] + arsse.php import [] + [-f | --flat] [-r | --replace] arsse.php --version arsse.php --help | -h @@ -70,7 +73,7 @@ USAGE_TEXT; 'help' => false, ]); try { - $cmd = $this->command(["--help", "--version", "daemon", "feed refresh", "feed refresh-all", "conf save-defaults", "user", "export"], $args); + $cmd = $this->command(["--help", "--version", "daemon", "feed refresh", "feed refresh-all", "conf save-defaults", "user", "export", "import"], $args); if ($cmd && !in_array($cmd, ["--help", "--version", "conf save-defaults"])) { // only certain commands don't require configuration to be loaded $this->loadConf(); @@ -99,6 +102,10 @@ USAGE_TEXT; $u = $args['']; $file = $this->resolveFile($args[''], "w"); return (int) !$this->getInstance(OPML::class)->exportFile($file, $u, $args['--flat']); + case "import": + $u = $args['']; + $file = $this->resolveFile($args[''], "w"); + return (int) !$this->getInstance(OPML::class)->importFile($file, $u, $args['--flat'], $args['--replace']); } } catch (AbstractException $e) { $this->logError($e->getMessage()); diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php index 616fa82..be7365a 100644 --- a/lib/ImportExport/OPML.php +++ b/lib/ImportExport/OPML.php @@ -19,14 +19,14 @@ class OPML { foreach ($folders as $f) { // check to make sure folder names are all valid if (!strlen(trim($f['name']))) { - throw new \Exception; + throw new Exception("invalidFolderName"); } // check for duplicates if (!isset($folderMap[$f['parent']])) { $folderMap[$f['parent']] = []; } if (isset($folderMap[$f['parent']][$f['name']])) { - throw new \Exception; + throw new Exception("invalidFolderCopy"); } else { $folderMap[$f['parent']][$f['name']] = true; } @@ -142,12 +142,13 @@ class OPML { $d = new \DOMDocument; if (!@$d->loadXML($opml)) { // not a valid XML document - throw new \Exception; + $err = libxml_get_last_error(); + throw new Exception("invalidSyntax", ['line' => $err->line, 'column' => $err->column]); } $body = $d->getElementsByTagName("body"); if ($d->documentElement->nodeName !== "opml" || !$body->length || $body->item(0)->parentNode != $d->documentElement) { // not a valid OPML document - throw new \Exception; + throw new Exception("invalidSemantics", ['type' => "OPML"]); } $body = $body->item(0); $folders = []; @@ -268,4 +269,14 @@ class OPML { } return true; } + + public function imortFile(string $file, string $user, bool $flat = false, bool $replace): bool { + $data = @file_get_contents($file); + if ($data === false) { + // if it fails throw an exception + $err = file_exists($file) ? "fileUnreadable" : "fileMissing"; + throw new Exception($err, ['file' => $file, 'format' => str_replace(__NAMESPACE__."\\", "", __CLASS__)]); + } + return $this->import($user, $data, $flat, $replace); + } } diff --git a/locale/en.php b/locale/en.php index a9fa045..19fc724 100644 --- a/locale/en.php +++ b/locale/en.php @@ -155,14 +155,12 @@ return [ 'Exception.JKingWeb/Arsse/Feed/Exception.xmlEntity' => 'Refused to parse feed "{url}" because it contains an XXE attack', 'Exception.JKingWeb/Arsse/Feed/Exception.subscriptionNotFound' => 'Unable to find a feed at location "{url}"', 'Exception.JKingWeb/Arsse/Feed/Exception.unsupportedFeedFormat' => 'Feed "{url}" is of an unsupported format', - 'Exception.JKingWeb/Arsse/ImportExport/Exception.fileUncreatable' => - 'Insufficient permissions to write {type, select, - OPML {OPML} - other {"{type}"} - } export to file "{file}"', - 'Exception.JKingWeb/Arsse/ImportExport/Exception.fileUnwritable' => - 'Insufficient permissions to write {type, select, - OPML {OPML} - other {"{type}"} - } export to existing file "{file}"', + 'Exception.JKingWeb/Arsse/ImportExport/Exception.fileMissing' => 'Import {type} file "{file}" does not exist', + 'Exception.JKingWeb/Arsse/ImportExport/Exception.fileUnreadable' => 'Insufficient permissions to read {type} file "{file}" for import', + 'Exception.JKingWeb/Arsse/ImportExport/Exception.fileUncreatable' => 'Insufficient permissions to write {type} export to file "{file}"', + 'Exception.JKingWeb/Arsse/ImportExport/Exception.fileUnwritable' => 'Insufficient permissions to write {type} export to existing file "{file}"', + 'Exception.JKingWeb/Arsse/ImportExport/Exception.invalidSyntax' => 'Input data syntax error at line {line}, column {column}', + 'Exception.JKingWeb/Arsse/ImportExport/Exception.invalidSemantics' => 'Input data is not valid {type} data', + 'Exception.JKingWeb/Arsse/ImportExport/Exception.invalidFolderName' => 'Input data contains an invalid folder name', + 'Exception.JKingWeb/Arsse/ImportExport/Exception.invalidFolderCopy' => 'Input data contains multiple folders of the same name under the same parent', ]; From 6ef13d08804dd9fabd143f7edb2e0f1cae9b00b1 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 1 May 2019 22:52:20 -0400 Subject: [PATCH 111/142] Style fixes --- lib/CLI.php | 1 + lib/Database.php | 246 +++++++++++----------- lib/Db/Driver.php | 14 +- lib/Db/SQLite3/PDODriver.php | 4 +- lib/Db/SQLite3/PDOStatement.php | 2 +- lib/Db/Statement.php | 2 +- lib/ImportExport/OPML.php | 4 +- lib/REST/TinyTinyRSS/Search.php | 8 +- tests/cases/Database/SeriesArticle.php | 2 +- tests/cases/ImportExport/TestOPMLFile.php | 1 - tests/cases/REST/Fever/TestAPI.php | 1 - 11 files changed, 145 insertions(+), 140 deletions(-) diff --git a/lib/CLI.php b/lib/CLI.php index e899896..5634ba6 100644 --- a/lib/CLI.php +++ b/lib/CLI.php @@ -136,6 +136,7 @@ USAGE_TEXT; } else { return $this->userAddOrSetPassword("passwordSet", $args[""], $args[""], $args["--oldpass"]); } + // no break case "unset-pass": if ($args['--fever']) { $this->getInstance(Fever::class)->unregister($args[""]); diff --git a/lib/Database.php b/lib/Database.php index 72a06e4..ef91591 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -14,9 +14,9 @@ use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\ValueInfo; /** The high-level interface with the database - * + * * The database stores information on the following things: - * + * * - Users * - Subscriptions to feeds, which belong to users * - Folders, which belong to users and contain subscriptions @@ -28,9 +28,9 @@ use JKingWeb\Arsse\Misc\ValueInfo; * - Sessions, used by some protocols to identify users across periods of time * - Tokens, similar to sessions, but with more control over their properties * - Metadata, used internally by the server - * + * * The various methods of this class perform operations on these things, with - * each public method prefixed with the thing it concerns e.g. userRemove() + * each public method prefixed with the thing it concerns e.g. userRemove() * deletes a user from the database, and labelArticlesSet() changes a label's * associations with articles. There has been an effort to keep public method * names consistent throughout, but protected methods, having different @@ -60,7 +60,7 @@ class Database { public $db; /** Constructs the database interface - * + * * @param boolean $initialize Whether to attempt to upgrade the databse schema when constructing */ public function __construct($initialize = true) { @@ -77,7 +77,7 @@ class Database { return debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]['function']; } - /** Lists the available database drivers, as an associative array with + /** Lists the available database drivers, as an associative array with * fully-qualified class names as keys, and human-readable descriptions as values */ public static function driverList(): array { @@ -111,9 +111,9 @@ class Database { } /** Computes the column and value text of an SQL "SET" clause, validating arbitrary input against a whitelist - * + * * Returns an indexed array containing the clause text, an array of types, and another array of values - * + * * @param array $props An associative array containing untrusted data; keys are column names * @param array $valid An associative array containing a whitelist: keys are column names, and values are strings representing data types */ @@ -136,9 +136,9 @@ class Database { } /** Computes the contents of an SQL "IN()" clause, for each input value either embedding the value or producing a parameter placeholder - * + * * Returns an indexed array containing the clause text, an array of types, and an array of values. Note that the array of output values may not match the array of input values - * + * * @param array $values Arbitrary values * @param string $type A single data type applied to each value */ @@ -153,7 +153,7 @@ class Database { $params = []; $count = 0; $convType = Db\AbstractStatement::TYPE_NORM_MAP[Statement::TYPES[$type]]; - foreach($values as $v) { + foreach ($values as $v) { $v = ValueInfo::normalize($v, $convType, null, "sql"); if (is_null($v)) { // nulls are pointless to have @@ -182,11 +182,11 @@ class Database { } /** Computes basic LIKE-based text search constraints for use in a WHERE clause - * + * * Returns an indexed array containing the clause text, an array of types, and another array of values - * + * * The clause is structured such that all terms must be present across any of the columns - * + * * @param string[] $terms The terms to search for * @param string[] $cols The columns to match against; these are -not- sanitized, so much -not- come directly from user input * @param boolean $matchAny Whether the search is successful when it matches any (true) or all (false) terms @@ -200,7 +200,7 @@ class Database { $values = []; $like = $this->db->sqlToken("like"); $embedSet = sizeof($terms) > ((int) (self::LIMIT_SET_SIZE / sizeof($cols))); - foreach($terms as $term) { + foreach ($terms as $term) { $embedTerm = ($embedSet && strlen($term) <= self::LIMIT_SET_STRING_LENGTH); $term = str_replace(["%", "_", "^"], ["^%", "^_", "^^"], $term); $term = "%$term%"; @@ -255,7 +255,7 @@ class Database { } /** Adds a user to the database - * + * * @param string $user The user to add * @param string $passwordThe user's password in cleartext. It will be stored hashed */ @@ -304,7 +304,7 @@ class Database { } /** Sets the password of an existing user - * + * * @param string $user The user for whom to set the password * @param string $password The new password, in cleartext. The password will be stored hashed. If null is passed, the password is unset and authentication not possible */ @@ -335,10 +335,10 @@ class Database { } /** Explicitly removes a session from the database - * - * Sessions may also be invalidated as they expire, and then be automatically pruned. + * + * Sessions may also be invalidated as they expire, and then be automatically pruned. * This function can be used to explicitly invalidate a session after a user logs out - * + * * @param string $user The user who owns the session to be destroyed * @param string $id The identifier of the session to destroy */ @@ -352,7 +352,7 @@ class Database { } /** Resumes a session, returning available session data - * + * * This also has the side effect of refreshing the session if it is near its timeout */ public function sessionResume(string $id): array { @@ -386,8 +386,8 @@ class Database { return (($now + $diff) >= $expiry->getTimestamp()); } - /** Creates a new token for the given user in the given class - * + /** Creates a new token for the given user in the given class + * * @param string $user The user for whom to create the token * @param string $class The class of the token e.g. the protocol name * @param string|null $id The value of the token; if none is provided a UUID will be generated @@ -409,7 +409,7 @@ class Database { } /** Revokes one or all tokens for a user in a class - * + * * @param string $user The user who owns the token to be revoked * @param string $class The class of the token e.g. the protocol name * @param string|null $id The ID of a specific token, or null for all tokens in the class @@ -442,14 +442,14 @@ class Database { } /** Adds a folder for containing newsfeed subscriptions, returning an integer identifying the created folder - * + * * The $data array may contain the following keys: - * + * * - "name": A folder name, which must be a non-empty string not composed solely of whitespace; this key is required * - "parent": An integer (or null) identifying a parent folder; this key is optional - * + * * If a folder with the same name and parent already exists, this is an error - * + * * @param string $user The user who will own the folder * @param array $data An associative array defining the folder */ @@ -468,15 +468,15 @@ class Database { } /** Returns a result set listing a user's folders - * + * * Each record in the result set contains: - * + * * - "id": The folder identifier, an integer * - "name": The folder's name, a string * - "parent": The integer identifier of the folder's parent, or null * - "children": The number of child folders contained in the given folder - * - "feeds": The number of newsfeed subscriptions contained in the given folder, not including subscriptions in descendent folders - * + * - "feeds": The number of newsfeed subscriptions contained in the given folder, not including subscriptions in descendent folders + * * @param string $uer The user whose folders are to be listed * @param integer|null $parent Restricts the list to the descendents of the specified folder identifier * @param boolean $recursive Whether to list all descendents (true) or only direct children (false) @@ -511,9 +511,9 @@ class Database { } /** Deletes a folder from the database - * + * * Any descendent folders are also deleted, as are all newsfeed subscriptions contained in the deleted folder tree - * + * * @param string $user The user to whom the folder to be deleted belongs * @param integer $id The identifier of the folder to delete */ @@ -547,14 +547,14 @@ class Database { } /** Modifies the properties of a folder - * + * * The $data array must contain one or more of the following keys: - * + * * - "name": A new folder name, which must be a non-empty string not composed solely of whitespace * - "parent": An integer (or null) identifying a parent folder - * + * * If a folder with the new name and parent combination already exists, this is an error; it is also an error to move a folder to itself or one of its descendents - * + * * @param string $user The user who owns the folder to be modified * @param integer $id The identifier of the folder to be modified * @param array $data An associative array of properties to modify. Anything not specified will remain unchanged @@ -596,9 +596,9 @@ class Database { } /** Ensures the specified folder exists and raises an exception otherwise - * - * Returns an associative array containing the id, name, and parent of the folder if it exists - * + * + * Returns an associative array containing the id, name, and parent of the folder if it exists + * * @param string $user The user who owns the folder to be validated * @param integer|null $id The identifier of the folder to validate; null or zero represent the implied root folder * @param boolean $subject Whether the folder is the subject (true) rather than the object (false) of the operation being performed; this only affects the semantics of the error message if validation fails @@ -674,7 +674,7 @@ class Database { } /** Ensures a prospective folder name is valid, and optionally ensure it is not a duplicate if renamed - * + * * @param string $name The name to check * @param boolean $checkDuplicates Whether to also check if the new name would cause a collision * @param integer|null $parent The parent folder context in which to check for duplication @@ -701,7 +701,7 @@ class Database { } /** Adds a subscription to a newsfeed, and returns the numeric identifier of the added subscription - * + * * @param string $user The user which will own the subscription * @param string $url The URL of the newsfeed or discovery source * @param string $fetchUser The user name required to access the newsfeed, if applicable @@ -719,7 +719,7 @@ class Database { } /** Lists a user's subscriptions, returning various data - * + * * @param string $user The user whose subscriptions are to be listed * @param integer|null $folder The identifier of the folder under which to list subscriptions; by default the root folder is used * @param boolean $recursive Whether to list subscriptions of descendent folders as well as the selected folder @@ -790,8 +790,8 @@ class Database { } /** Deletes a subscription from the database - * - * This has the side effect of deleting all marks the user has set on articles + * + * This has the side effect of deleting all marks the user has set on articles * belonging to the newsfeed, but may not delete the articles themselves, as * other users may also be subscribed to the same newsfeed. There is also a * configurable retention period for newsfeeds @@ -811,7 +811,7 @@ class Database { } /** Retrieves data about a particular subscription, as an associative array with the following keys: - * + * * - "id": The numeric identifier of the subscription * - "feed": The numeric identifier of the underlying newsfeed * - "url": The URL of the newsfeed, after discovery and HTTP redirects @@ -843,14 +843,14 @@ class Database { } /** Modifies the properties of a subscription - * + * * The $data array must contain one or more of the following keys: - * + * * - "title": The title of the newsfeed * - "folder": The numeric identifier (or null) of the subscription's folder * - "pinned": Whether the subscription is pinned * - "order_type": Whether articles should be sorted in reverse cronological order (2), chronological order (1), or the default (0) - * + * * @param string $user The user whose subscription is to be modified * @param integer $id the numeric identifier of the subscription to modfify * @param array $data An associative array of properties to modify; any keys not specified will be left unchanged @@ -896,7 +896,7 @@ class Database { } /** Returns an indexed array listing the tags assigned to a subscription - * + * * @param string $user The user whose tags are to be listed * @param integer $id The numeric identifier of the subscription whose tags are to be listed * @param boolean $byName Whether to return the tag names (true) instead of the numeric tag identifiers (false) @@ -912,14 +912,14 @@ class Database { } /** Retrieves the URL of the icon for a subscription. - * + * * Note that while the $user parameter is optional, it - * is NOT recommended to omit it, as this can lead to - * leaks of private information. The parameter is only + * is NOT recommended to omit it, as this can lead to + * leaks of private information. The parameter is only * optional because this is required for Tiny Tiny RSS, * the original implementation of which leaks private * information due to a design flaw. - * + * * @param integer $id The numeric identifier of the subscription * @param string|null $user The user who owns the subscription being queried */ @@ -953,9 +953,9 @@ class Database { } /** Ensures the specified subscription exists and raises an exception otherwise - * + * * Returns an associative array containing the id of the subscription and the id of the underlying newsfeed - * + * * @param string $user The user who owns the subscription to be validated * @param integer $id The identifier of the subscription to validate * @param boolean $subject Whether the subscription is the subject (true) rather than the object (false) of the operation being performed; this only affects the semantics of the error message if validation fails @@ -972,9 +972,9 @@ class Database { } /** Adds a newsfeed to the database without adding any subscriptions, and returns the numeric identifier of the added feed - * + * * If the feed already exists in the database, the existing ID is returned - * + * * @param string $url The URL of the newsfeed or discovery source * @param string $fetchUser The user name required to access the newsfeed, if applicable * @param string $fetchPassword The password required to fetch the newsfeed, if applicable; this will be stored in cleartext @@ -1011,7 +1011,7 @@ class Database { } /** Attempts to refresh a newsfeed, returning an indication of success - * + * * @param integer $feedID The numerical identifier of the newsfeed to refresh * @param boolean $throwError Whether to throw an exception on failure in addition to storing error information in the database */ @@ -1167,7 +1167,7 @@ class Database { } /** Deletes orphaned newsfeeds from the database - * + * * Newsfeeds are orphaned if no users are subscribed to them. Deleting a newsfeed also deletes its articles */ public function feedCleanup(): bool { @@ -1188,14 +1188,14 @@ class Database { } /** Retrieves various identifiers for the latest $count articles in the given newsfeed. The identifiers are: - * + * * - "id": The database record key for the article * - "guid": The (theoretically) unique identifier for the article * - "edited": The time at which the article was last edited, per the newsfeed * - "url_title_hash": A cryptographic hash of the article URL and its title * - "url_content_hash": A cryptographic hash of the article URL and its content * - "title_content_hash": A cryptographic hash of the article title and its content - * + * * @param integer $feedID The numeric identifier of the feed * @param integer $count The number of records to return */ @@ -1208,14 +1208,14 @@ class Database { } /** Retrieves various identifiers for articles in the given newsfeed which match the input identifiers. The output identifiers are: - * + * * - "id": The database record key for the article * - "guid": The (theoretically) unique identifier for the article * - "edited": The time at which the article was last edited, per the newsfeed * - "url_title_hash": A cryptographic hash of the article URL and its title * - "url_content_hash": A cryptographic hash of the article URL and its content * - "title_content_hash": A cryptographic hash of the article title and its content - * + * * @param integer $feedID The numeric identifier of the feed * @param array $ids An array of GUIDs of articles * @param array $hashesUT An array of hashes of articles' URL and title @@ -1240,9 +1240,9 @@ class Database { } /** Computes an SQL query to find and retrieve data about articles in the database - * + * * If an empty column list is supplied, a count of articles matching the context is queried instead - * + * * @param string $user The user whose articles are to be queried * @param Context $context The search context * @param array $cols The columns to request in the result set @@ -1254,13 +1254,13 @@ class Database { } if ($context->folder()) { $this->folderValidateId($user, $context->folder); - } + } if ($context->folderShallow()) { $this->folderValidateId($user, $context->folderShallow); } if ($context->edition()) { $this->articleValidateEdition($user, $context->edition); - } + } if ($context->article()) { $this->articleValidateId($user, $context->article); } @@ -1362,7 +1362,7 @@ class Database { } elseif ($pair && $context->$pair()) { // option is paired with another which is also being used if ($op === ">=") { - $q->setWhere("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], [$context->$m, $context->$pair]); + $q->setWhere("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], [$context->$m, $context->$pair]); } else { // option has already been paired continue; @@ -1386,7 +1386,7 @@ class Database { } elseif ($pair && $context->not->$pair()) { // option is paired with another which is also being used if ($op === ">=") { - $q->setWhereNot("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], [$context->not->$m, $context->not->$pair]); + $q->setWhereNot("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], [$context->not->$m, $context->not->$pair]); } else { // option has already been paired continue; @@ -1406,7 +1406,7 @@ class Database { $q->setWhere("coalesce(label_stats.assigned,0) $op 0"); } if ($context->label() || $context->not->label() || $context->labelName() || $context->not->labelName()) { - $q->setCTE("labelled(article,label_id,label_name)","SELECT m.article, l.id, l.name from arsse_label_members as m join arsse_labels as l on l.id = m.label where l.owner = ? and m.assigned = 1", "str", $user); + $q->setCTE("labelled(article,label_id,label_name)", "SELECT m.article, l.id, l.name from arsse_label_members as m join arsse_labels as l on l.id = m.label where l.owner = ? and m.assigned = 1", "str", $user); if ($context->label()) { $q->setWhere("arsse_articles.id in (select article from labelled where label_id = ?)", "int", $context->label); } @@ -1421,7 +1421,7 @@ class Database { } } if ($context->tag() || $context->not->tag() || $context->tagName() || $context->not->tagName()) { - $q->setCTE("tagged(id,name,subscription)","SELECT arsse_tags.id, arsse_tags.name, arsse_tag_members.subscription FROM arsse_tag_members join arsse_tags on arsse_tags.id = arsse_tag_members.tag WHERE arsse_tags.owner = ? and assigned = 1", "str", $user); + $q->setCTE("tagged(id,name,subscription)", "SELECT arsse_tags.id, arsse_tags.name, arsse_tag_members.subscription FROM arsse_tag_members join arsse_tags on arsse_tags.id = arsse_tag_members.tag WHERE arsse_tags.owner = ? and assigned = 1", "str", $user); if ($context->tag()) { $q->setWhere("arsse_subscriptions.id in (select subscription from tagged where id = ?)", "int", $context->tag); } @@ -1474,9 +1474,9 @@ class Database { } /** Lists articles in the database which match a given query context - * + * * If an empty column list is supplied, a count of articles is returned instead - * + * * @param string $user The user whose articles are to be listed * @param Context $context The search context * @param array $cols The columns to return in the result set, any of: id, edition, url, title, author, content, guid, fingerprint, folder, subscription, feed, starred, unread, note, published_date, edited_date, modified_date, marked_date, subscription_title, media_url, media_type @@ -1494,7 +1494,7 @@ class Database { } /** Returns a count of articles which match the given query context - * + * * @param string $user The user whose articles are to be counted * @param Context $context The search context */ @@ -1508,13 +1508,13 @@ class Database { } /** Applies one or multiple modifications to all articles matching the given query context - * + * * The $data array enumerates the modifications to perform and must contain one or more of the following keys: - * + * * - "read": Whether the article should be marked as read (true) or unread (false) * - "starred": Whether the article should (true) or should not (false) be marked as starred/favourite * - "note": A string containing a freeform plain-text note for the article - * + * * @param string $user The user who owns the articles to be modified * @param array $data An associative array of properties to modify. Anything not specified will remain unchanged * @param Context $context The query context to match articles against @@ -1598,9 +1598,9 @@ class Database { } /** Returns statistics about the articles starred by the given user - * + * * The associative array returned has the following keys: - * + * * - "total": The count of all starred articles * - "unread": The count of starred articles which are unread * - "read": The count of starred articles which are read @@ -1622,7 +1622,7 @@ class Database { } /** Returns an indexed array listing the labels assigned to an article - * + * * @param string $user The user whose labels are to be listed * @param integer $id The numeric identifier of the article whose labels are to be listed * @param boolean $byName Whether to return the label names (true) instead of the numeric label identifiers (false) @@ -1686,9 +1686,9 @@ class Database { } /** Ensures the specified article exists and raises an exception otherwise - * - * Returns an associative array containing the id and latest edition of the article if it exists - * + * + * Returns an associative array containing the id and latest edition of the article if it exists + * * @param string $user The user who owns the article to be validated * @param integer $id The identifier of the article to validate */ @@ -1713,9 +1713,9 @@ class Database { } /** Ensures the specified article edition exists and raises an exception otherwise - * - * Returns an associative array containing the edition id, article id, and latest edition of the edition if it exists - * + * + * Returns an associative array containing the edition id, article id, and latest edition of the edition if it exists + * * @param string $user The user who owns the edition to be validated * @param integer $id The identifier of the edition to validate */ @@ -1766,9 +1766,9 @@ class Database { } /** Creates a label, and returns its numeric identifier - * + * * Labels are discrete objects in the database and can be associated with multiple articles; an article may in turn be associated with multiple labels - * + * * @param string $user The user who will own the created label * @param array $data An associative array defining the label's properties; currently only "name" is understood */ @@ -1785,14 +1785,14 @@ class Database { } /** Lists a user's article labels - * + * * The following keys are included in each record: - * + * * - "id": The label's numeric identifier * - "name" The label's textual name * - "articles": The count of articles which have the label assigned to them * - "read": How many of the total articles assigned to the label are read - * + * * @param string $user The user whose labels are to be listed * @param boolean $includeEmpty Whether to include (true) or supress (false) labels which have no articles assigned to them */ @@ -1829,9 +1829,9 @@ class Database { } /** Deletes a label from the database - * + * * Any articles associated with the label remains untouched - * + * * @param string $user The owner of the label to remove * @param integer|string $id The numeric identifier or name of the label * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) @@ -1851,14 +1851,14 @@ class Database { } /** Retrieves the properties of a label - * + * * The following keys are included in the output array: - * + * * - "id": The label's numeric identifier * - "name" The label's textual name * - "articles": The count of articles which have the label assigned to them * - "read": How many of the total articles assigned to the label are read - * + * * @param string $user The owner of the label to remove * @param integer|string $id The numeric identifier or name of the label * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) @@ -1899,7 +1899,7 @@ class Database { } /** Sets the properties of a label - * + * * @param string $user The owner of the label to query * @param integer|string $id The numeric identifier or name of the label * @param array $data An associative array defining the label's properties; currently only "name" is understood @@ -1931,7 +1931,7 @@ class Database { } /** Returns an indexed array of article identifiers assigned to a label - * + * * @param string $user The owner of the label to query * @param integer|string $id The numeric identifier or name of the label * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) @@ -1957,7 +1957,7 @@ class Database { } /** Makes or breaks associations between a given label and articles matching the given query context - * + * * @param string $user The owner of the label * @param integer|string $id The numeric identifier or name of the label * @param Context $context The query context matching the desired articles @@ -2022,9 +2022,9 @@ class Database { } /** Ensures the specified label identifier or name is valid (and optionally whether it exists) and raises an exception otherwise - * - * Returns an associative array containing the id, name of the label if it exists - * + * + * Returns an associative array containing the id, name of the label if it exists + * * @param string $user The user who owns the label to be validated * @param integer|string $id The numeric identifier or name of the label to validate * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) @@ -2069,9 +2069,9 @@ class Database { } /** Creates a tag, and returns its numeric identifier - * + * * Tags are discrete objects in the database and can be associated with multiple subscriptions; a subscription may in turn be associated with multiple tags - * + * * @param string $user The user who will own the created tag * @param array $data An associative array defining the tag's properties; currently only "name" is understood */ @@ -2088,13 +2088,13 @@ class Database { } /** Lists a user's subscription tags - * + * * The following keys are included in each record: - * + * * - "id": The tag's numeric identifier * - "name" The tag's textual name * - "subscriptions": The count of subscriptions which have the tag assigned to them - * + * * @param string $user The user whose tags are to be listed * @param boolean $includeEmpty Whether to include (true) or supress (false) tags which have no subscriptions assigned to them */ @@ -2119,14 +2119,14 @@ class Database { } /** Lists the associations between all tags and subscription - * + * * The following keys are included in each record: - * + * * - "tag_id": The tag's numeric identifier * - "tag_name" The tag's textual name * - "subscription_id": The numeric identifier of the associated subscription * - "subscription_name" The subscription's textual name - * + * * @param string $user The user whose tags are to be listed */ public function tagSummarize(string $user): Db\Result { @@ -2147,9 +2147,9 @@ class Database { } /** Deletes a tag from the database - * + * * Any subscriptions associated with the tag remains untouched - * + * * @param string $user The owner of the tag to remove * @param integer|string $id The numeric identifier or name of the tag * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) @@ -2169,13 +2169,13 @@ class Database { } /** Retrieves the properties of a tag - * + * * The following keys are included in the output array: - * + * * - "id": The tag's numeric identifier * - "name" The tag's textual name * - "subscriptions": The count of subscriptions which have the tag assigned to them - * + * * @param string $user The owner of the tag to remove * @param integer|string $id The numeric identifier or name of the tag * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) @@ -2204,7 +2204,7 @@ class Database { } /** Sets the properties of a tag - * + * * @param string $user The owner of the tag to query * @param integer|string $id The numeric identifier or name of the tag * @param array $data An associative array defining the tag's properties; currently only "name" is understood @@ -2236,7 +2236,7 @@ class Database { } /** Returns an indexed array of subscription identifiers assigned to a tag - * + * * @param string $user The owner of the tag to query * @param integer|string $id The numeric identifier or name of the tag * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) @@ -2262,7 +2262,7 @@ class Database { } /** Makes or breaks associations between a given tag and specified subscriptions - * + * * @param string $user The owner of the tag * @param integer|string $id The numeric identifier or name of the tag * @param integer[] $subscriptions An array listing the desired subscriptions @@ -2323,9 +2323,9 @@ class Database { } /** Ensures the specified tag identifier or name is valid (and optionally whether it exists) and raises an exception otherwise - * - * Returns an associative array containing the id, name of the tag if it exists - * + * + * Returns an associative array containing the id, name of the tag if it exists + * * @param string $user The user who owns the tag to be validated * @param integer|string $id The numeric identifier or name of the tag to validate * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) diff --git a/lib/Db/Driver.php b/lib/Db/Driver.php index b0f572c..7f04dc6 100644 --- a/lib/Db/Driver.php +++ b/lib/Db/Driver.php @@ -20,7 +20,7 @@ interface Driver { public static function driverName(): string; /** Returns the version of the schema of the opened database; if uninitialized should return 0 - * + * * Normally the version is stored under the 'schema_version' key in the arsse_meta table, but another method may be used if appropriate */ public function schemaVersion(): int; @@ -32,7 +32,7 @@ interface Driver { public function begin(bool $lock = false): Transaction; /** Manually begins a real or synthetic transactions, with real or synthetic nesting, and returns its numeric ID - * + * * If the database backend does not implement savepoints, IDs must still be tracked as if it does */ public function savepointCreate(): int; @@ -44,7 +44,7 @@ interface Driver { public function savepointUndo(int $index = null): bool; /** Performs an in-place upgrade of the database schema - * + * * The driver may choose not to implement in-place upgrading, in which case an exception should be thrown */ public function schemaUpdate(int $to): bool; @@ -62,15 +62,15 @@ interface Driver { public function prepareArray(string $query, array $paramTypes): Statement; /** Reports whether the database character set is correct/acceptable - * + * * The backend must be able to accept and provide UTF-8 text; information may be stored in any encoding capable of representing the entire range of Unicode */ public function charsetAcceptable(): bool; /** Returns an implementation-dependent form of a reference SQL function or operator - * + * * The tokens the implementation must understand are: - * + * * - "greatest": the GREATEST function implemented by PostgreSQL and MySQL * - "nocase": the name of a general-purpose case-insensitive collation sequence * - "like": the case-insensitive LIKE operator @@ -78,7 +78,7 @@ interface Driver { public function sqlToken(string $token): string; /** Returns a string literal which is properly escaped to guard against SQL injections. Delimiters are included in the output string - * + * * This functionality should be avoided in favour of using statement parameters whenever possible */ public function literalString(string $str): string; diff --git a/lib/Db/SQLite3/PDODriver.php b/lib/Db/SQLite3/PDODriver.php index b1cff19..c6d7ad4 100644 --- a/lib/Db/SQLite3/PDODriver.php +++ b/lib/Db/SQLite3/PDODriver.php @@ -50,7 +50,7 @@ class PDODriver extends AbstractPDODriver { /** @codeCoverageIgnore */ public function exec(string $query): bool { - // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), + // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), // we have to retry ourselves in cases of schema changes // the SQLite3 class is not similarly affected $attempts = 0; @@ -68,7 +68,7 @@ class PDODriver extends AbstractPDODriver { /** @codeCoverageIgnore */ public function query(string $query): \JKingWeb\Arsse\Db\Result { - // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), + // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), // we have to retry ourselves in cases of schema changes // the SQLite3 class is not similarly affected $attempts = 0; diff --git a/lib/Db/SQLite3/PDOStatement.php b/lib/Db/SQLite3/PDOStatement.php index 166fe31..eb4fdfe 100644 --- a/lib/Db/SQLite3/PDOStatement.php +++ b/lib/Db/SQLite3/PDOStatement.php @@ -12,7 +12,7 @@ class PDOStatement extends \JKingWeb\Arsse\Db\PDOStatement { /** @codeCoverageIgnore */ public function runArray(array $values = []): \JKingWeb\Arsse\Db\Result { - // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), + // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), // we have to retry ourselves in cases of schema changes // the SQLite3 class is not similarly affected $attempts = 0; diff --git a/lib/Db/Statement.php b/lib/Db/Statement.php index b85ceca..0ed8685 100644 --- a/lib/Db/Statement.php +++ b/lib/Db/Statement.php @@ -24,7 +24,7 @@ interface Statement { 'str' => self::T_STRING, 'bool' => self::T_BOOLEAN, 'boolean' => self::T_BOOLEAN, - 'bit' => self::T_BOOLEAN, + 'bit' => self::T_BOOLEAN, 'strict int' => self::T_NOT_NULL + self::T_INTEGER, 'strict integer' => self::T_NOT_NULL + self::T_INTEGER, 'strict float' => self::T_NOT_NULL + self::T_FLOAT, diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php index be7365a..fd599a2 100644 --- a/lib/ImportExport/OPML.php +++ b/lib/ImportExport/OPML.php @@ -69,7 +69,7 @@ class OPML { if ((int) $db['feed'] == $f['id']) { $found = true; $feedMap[$f['id']] = (int) $db['id']; - break; + break; } } if (!$found) { @@ -138,7 +138,7 @@ class OPML { return true; } - protected function parse(string $opml, bool $flat): array { + public function parse(string $opml, bool $flat): array { $d = new \DOMDocument; if (!@$d->loadXML($opml)) { // not a valid XML document diff --git a/lib/REST/TinyTinyRSS/Search.php b/lib/REST/TinyTinyRSS/Search.php index 4ff634b..f791361 100644 --- a/lib/REST/TinyTinyRSS/Search.php +++ b/lib/REST/TinyTinyRSS/Search.php @@ -82,6 +82,7 @@ class Search { $state = self::STATE_IN_TOKEN_OR_TAG; continue 3; } + // no break case self::STATE_BEFORE_TOKEN_QUOTED: switch ($char) { case "": @@ -130,6 +131,7 @@ class Search { $state = self::STATE_IN_TOKEN_OR_TAG_QUOTED; continue 3; } + // no break case self::STATE_IN_DATE: while ($pos < $stop && $search[$pos] !== " ") { $buffer .= $search[$pos++]; @@ -169,6 +171,7 @@ class Search { $buffer .= $char; continue 3; } + // no break case self::STATE_IN_TOKEN: while ($pos < $stop && $search[$pos] !== " ") { $buffer .= $search[$pos++]; @@ -214,6 +217,7 @@ class Search { $buffer .= $char; continue 3; } + // no break case self::STATE_IN_TOKEN_OR_TAG: switch ($char) { case "": @@ -223,7 +227,7 @@ class Search { $flag_negative = false; $buffer = $tag = ""; continue 3; - case ":"; + case ":": $tag = $buffer; $buffer = ""; $state = self::STATE_IN_TOKEN; @@ -232,6 +236,7 @@ class Search { $buffer .= $char; continue 3; } + // no break case self::STATE_IN_TOKEN_OR_TAG_QUOTED: switch ($char) { case "": @@ -267,6 +272,7 @@ class Search { $buffer .= $char; continue 3; } + // no break default: throw new \Exception; // @codeCoverageIgnore } diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 5340fcc..048cd18 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -491,7 +491,7 @@ trait SeriesArticle { 'Excluded folder tree' => [(new Context)->not->folder(1), [1,2,3,4,19,20]], 'Excluding label ID 2' => [(new Context)->not->label(2), [2,3,4,6,7,8,19]], 'Excluding label "Fascinating"' => [(new Context)->not->labelName("Fascinating"), [2,3,4,6,7,8,19]], - 'Search 501 terms' => [(new Context)->searchTerms(array_merge(range(1,500),[str_repeat("a", 1000)])), []], + 'Search 501 terms' => [(new Context)->searchTerms(array_merge(range(1, 500), [str_repeat("a", 1000)])), []], 'With tag ID 1' => [(new Context)->tag(1), [5,6,7,8]], 'With tag ID 5' => [(new Context)->tag(5), [7,8,19,20]], 'With tag "Technology"' => [(new Context)->tagName("Technology"), [5,6,7,8]], diff --git a/tests/cases/ImportExport/TestOPMLFile.php b/tests/cases/ImportExport/TestOPMLFile.php index ecb601d..37b9e61 100644 --- a/tests/cases/ImportExport/TestOPMLFile.php +++ b/tests/cases/ImportExport/TestOPMLFile.php @@ -10,7 +10,6 @@ use JKingWeb\Arsse\ImportExport\OPML; use JKingWeb\Arsse\ImportExport\Exception; use org\bovigo\vfs\vfsStream; - /** @covers \JKingWeb\Arsse\ImportExport\OPML */ class TestOPMLFile extends \JKingWeb\Arsse\Test\AbstractTest { protected $vfs; diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index 272a25f..1986db0 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -26,7 +26,6 @@ use Zend\Diactoros\Response\EmptyResponse; /** @covers \JKingWeb\Arsse\REST\Fever\API */ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { - protected function v($value) { return $value; } From 5ba009cfed30a0612028586f615888c4f38cc4a3 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 2 May 2019 12:52:52 -0400 Subject: [PATCH 112/142] First set of OPML parser tests --- lib/ImportExport/OPML.php | 2 +- tests/bootstrap.php | 1 + tests/cases/ImportExport/TestOPML.php | 22 +++++++++++++++++++++ tests/docroot/Import/OPML/BrokenOPML.1.opml | 1 + tests/docroot/Import/OPML/BrokenOPML.2.opml | 1 + tests/docroot/Import/OPML/BrokenOPML.3.opml | 5 +++++ tests/docroot/Import/OPML/BrokenXML.opml | 1 + 7 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 tests/docroot/Import/OPML/BrokenOPML.1.opml create mode 100644 tests/docroot/Import/OPML/BrokenOPML.2.opml create mode 100644 tests/docroot/Import/OPML/BrokenOPML.3.opml create mode 100644 tests/docroot/Import/OPML/BrokenXML.opml diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php index fd599a2..b8711bd 100644 --- a/lib/ImportExport/OPML.php +++ b/lib/ImportExport/OPML.php @@ -146,7 +146,7 @@ class OPML { throw new Exception("invalidSyntax", ['line' => $err->line, 'column' => $err->column]); } $body = $d->getElementsByTagName("body"); - if ($d->documentElement->nodeName !== "opml" || !$body->length || $body->item(0)->parentNode != $d->documentElement) { + if ($d->documentElement->nodeName !== "opml" || !$body->length || !$body->item(0)->parentNode->isSameNode($d->documentElement)) { // not a valid OPML document throw new Exception("invalidSemantics", ['type' => "OPML"]); } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 59c04a1..68c7ea8 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -8,6 +8,7 @@ namespace JKingWeb\Arsse; const NS_BASE = __NAMESPACE__."\\"; define(NS_BASE."BASE", dirname(__DIR__).DIRECTORY_SEPARATOR); +const DOCROOT = BASE."tests".DIRECTORY_SEPARATOR."docroot".DIRECTORY_SEPARATOR; ini_set("memory_limit", "-1"); error_reporting(\E_ALL); require_once BASE."vendor".DIRECTORY_SEPARATOR."autoload.php"; diff --git a/tests/cases/ImportExport/TestOPML.php b/tests/cases/ImportExport/TestOPML.php index 2c8d7d2..a17cdaf 100644 --- a/tests/cases/ImportExport/TestOPML.php +++ b/tests/cases/ImportExport/TestOPML.php @@ -9,6 +9,7 @@ namespace JKingWeb\Arsse\TestCase\ImportExport; use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Test\Result; use JKingWeb\Arsse\ImportExport\OPML; +use JKingWeb\Arsse\ImportExport\Exception; /** @covers \JKingWeb\Arsse\ImportExport\OPML */ class TestOPML extends \JKingWeb\Arsse\Test\AbstractTest { @@ -104,4 +105,25 @@ OPML_EXPORT_SERIALIZATION; $this->assertException("doesNotExist", "User"); (new OPML)->export("john.doe@example.com"); } + + /** @dataProvider provideParserData */ + public function testParseOpmlForImport(string $file, bool $flat, $exp) { + $data = file_get_contents(\JKingWeb\Arsse\DOCROOT."Import/OPML/$file"); + $parser = new OPML; + if ($exp instanceof \JKingWeb\Arsse\AbstractException) { + $this->assertException($exp); + $parser->parse($data, $flat); + } else { + $this->assertSame($exp, $parse->parse($data, $flat)); + } + } + + public function provideParserData() { + return [ + ["BrokenXML.opml", false, new Exception("invalidSyntax")], + ["BrokenOPML.1.opml", false, new Exception("invalidSemantics")], + ["BrokenOPML.2.opml", false, new Exception("invalidSemantics")], + ["BrokenOPML.3.opml", false, new Exception("invalidSemantics")], + ]; + } } diff --git a/tests/docroot/Import/OPML/BrokenOPML.1.opml b/tests/docroot/Import/OPML/BrokenOPML.1.opml new file mode 100644 index 0000000..1f551ea --- /dev/null +++ b/tests/docroot/Import/OPML/BrokenOPML.1.opml @@ -0,0 +1 @@ + diff --git a/tests/docroot/Import/OPML/BrokenOPML.2.opml b/tests/docroot/Import/OPML/BrokenOPML.2.opml new file mode 100644 index 0000000..a6c0801 --- /dev/null +++ b/tests/docroot/Import/OPML/BrokenOPML.2.opml @@ -0,0 +1 @@ + diff --git a/tests/docroot/Import/OPML/BrokenOPML.3.opml b/tests/docroot/Import/OPML/BrokenOPML.3.opml new file mode 100644 index 0000000..466ca0c --- /dev/null +++ b/tests/docroot/Import/OPML/BrokenOPML.3.opml @@ -0,0 +1,5 @@ + + + + + diff --git a/tests/docroot/Import/OPML/BrokenXML.opml b/tests/docroot/Import/OPML/BrokenXML.opml new file mode 100644 index 0000000..95028ac --- /dev/null +++ b/tests/docroot/Import/OPML/BrokenXML.opml @@ -0,0 +1 @@ + From cdd9f4dfbeb26faa4f077d4fb091a0219e36e764 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 2 May 2019 21:54:49 -0400 Subject: [PATCH 113/142] More OPML parser tests --- lib/ImportExport/OPML.php | 4 ++-- tests/cases/ImportExport/TestOPML.php | 6 +++++- tests/docroot/Import/OPML/BrokenOPML.1.opml | 1 + tests/docroot/Import/OPML/BrokenOPML.2.opml | 1 + tests/docroot/Import/OPML/BrokenOPML.3.opml | 1 + tests/docroot/Import/OPML/BrokenOPML.4.opml | 5 +++++ tests/docroot/Import/OPML/BrokenXML.opml | 1 + tests/docroot/Import/OPML/Empty.1.opml | 4 ++++ tests/docroot/Import/OPML/Empty.2.opml | 9 +++++++++ tests/docroot/Import/OPML/Empty.3.opml | 11 +++++++++++ 10 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 tests/docroot/Import/OPML/BrokenOPML.4.opml create mode 100644 tests/docroot/Import/OPML/Empty.1.opml create mode 100644 tests/docroot/Import/OPML/Empty.2.opml create mode 100644 tests/docroot/Import/OPML/Empty.3.opml diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php index b8711bd..ca02f2d 100644 --- a/lib/ImportExport/OPML.php +++ b/lib/ImportExport/OPML.php @@ -145,8 +145,8 @@ class OPML { $err = libxml_get_last_error(); throw new Exception("invalidSyntax", ['line' => $err->line, 'column' => $err->column]); } - $body = $d->getElementsByTagName("body"); - if ($d->documentElement->nodeName !== "opml" || !$body->length || !$body->item(0)->parentNode->isSameNode($d->documentElement)) { + $body = (new \DOMXPath($d))->query("/opml/body"); + if ($body->length != 1) { // not a valid OPML document throw new Exception("invalidSemantics", ['type' => "OPML"]); } diff --git a/tests/cases/ImportExport/TestOPML.php b/tests/cases/ImportExport/TestOPML.php index a17cdaf..3390444 100644 --- a/tests/cases/ImportExport/TestOPML.php +++ b/tests/cases/ImportExport/TestOPML.php @@ -114,7 +114,7 @@ OPML_EXPORT_SERIALIZATION; $this->assertException($exp); $parser->parse($data, $flat); } else { - $this->assertSame($exp, $parse->parse($data, $flat)); + $this->assertSame($exp, $parser->parse($data, $flat)); } } @@ -124,6 +124,10 @@ OPML_EXPORT_SERIALIZATION; ["BrokenOPML.1.opml", false, new Exception("invalidSemantics")], ["BrokenOPML.2.opml", false, new Exception("invalidSemantics")], ["BrokenOPML.3.opml", false, new Exception("invalidSemantics")], + ["BrokenOPML.4.opml", false, new Exception("invalidSemantics")], + ["Empty.1.opml", false, [[], []]], + ["Empty.2.opml", false, [[], []]], + ["Empty.3.opml", false, [[], []]], ]; } } diff --git a/tests/docroot/Import/OPML/BrokenOPML.1.opml b/tests/docroot/Import/OPML/BrokenOPML.1.opml index 1f551ea..a626ae0 100644 --- a/tests/docroot/Import/OPML/BrokenOPML.1.opml +++ b/tests/docroot/Import/OPML/BrokenOPML.1.opml @@ -1 +1,2 @@ + diff --git a/tests/docroot/Import/OPML/BrokenOPML.2.opml b/tests/docroot/Import/OPML/BrokenOPML.2.opml index a6c0801..ac70153 100644 --- a/tests/docroot/Import/OPML/BrokenOPML.2.opml +++ b/tests/docroot/Import/OPML/BrokenOPML.2.opml @@ -1 +1,2 @@ + diff --git a/tests/docroot/Import/OPML/BrokenOPML.3.opml b/tests/docroot/Import/OPML/BrokenOPML.3.opml index 466ca0c..b087a1b 100644 --- a/tests/docroot/Import/OPML/BrokenOPML.3.opml +++ b/tests/docroot/Import/OPML/BrokenOPML.3.opml @@ -3,3 +3,4 @@ + diff --git a/tests/docroot/Import/OPML/BrokenOPML.4.opml b/tests/docroot/Import/OPML/BrokenOPML.4.opml new file mode 100644 index 0000000..544e4c3 --- /dev/null +++ b/tests/docroot/Import/OPML/BrokenOPML.4.opml @@ -0,0 +1,5 @@ + + + + + diff --git a/tests/docroot/Import/OPML/BrokenXML.opml b/tests/docroot/Import/OPML/BrokenXML.opml index 95028ac..0cbc6fe 100644 --- a/tests/docroot/Import/OPML/BrokenXML.opml +++ b/tests/docroot/Import/OPML/BrokenXML.opml @@ -1 +1,2 @@ + diff --git a/tests/docroot/Import/OPML/Empty.1.opml b/tests/docroot/Import/OPML/Empty.1.opml new file mode 100644 index 0000000..4999faa --- /dev/null +++ b/tests/docroot/Import/OPML/Empty.1.opml @@ -0,0 +1,4 @@ + + + + diff --git a/tests/docroot/Import/OPML/Empty.2.opml b/tests/docroot/Import/OPML/Empty.2.opml new file mode 100644 index 0000000..6dcd03f --- /dev/null +++ b/tests/docroot/Import/OPML/Empty.2.opml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/tests/docroot/Import/OPML/Empty.3.opml b/tests/docroot/Import/OPML/Empty.3.opml new file mode 100644 index 0000000..59fd9b4 --- /dev/null +++ b/tests/docroot/Import/OPML/Empty.3.opml @@ -0,0 +1,11 @@ + + + + + + + + + + + From a30114807fd28019fabd8d36dd2c789ad7a8e51d Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sun, 5 May 2019 20:29:44 -0400 Subject: [PATCH 114/142] Tests and fixed for OPML feed parsing --- lib/ImportExport/OPML.php | 4 +-- tests/cases/ImportExport/TestOPML.php | 38 ++++++++++++++++++++++++ tests/docroot/Import/OPML/FeedsOnly.opml | 12 ++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 tests/docroot/Import/OPML/FeedsOnly.opml diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php index ca02f2d..7328413 100644 --- a/lib/ImportExport/OPML.php +++ b/lib/ImportExport/OPML.php @@ -158,7 +158,7 @@ class OPML { $folderMap[$body] = sizeof($folderMap); // iterate through each node in the body $node = $body->firstChild; - while ($node && $node != $body) { + while ($node && !$node->isSameNode($body)) { if ($node->nodeType == \XML_ELEMENT_NODE && $node->nodeName === "outline") { // process any nodes which are outlines if ($node->getAttribute("type") === "rss") { @@ -170,7 +170,7 @@ class OPML { if (strlen($categories)) { // collapse and trim whitespace from category names, if any, splitting along commas $categories = array_map(function($v) { - return trim(preg_replace("/\s+/g", " ", $v)); + return trim(preg_replace("/\s+/", " ", $v)); }, explode(",", $categories)); } else { $categories = []; diff --git a/tests/cases/ImportExport/TestOPML.php b/tests/cases/ImportExport/TestOPML.php index 3390444..94d1b05 100644 --- a/tests/cases/ImportExport/TestOPML.php +++ b/tests/cases/ImportExport/TestOPML.php @@ -128,6 +128,44 @@ OPML_EXPORT_SERIALIZATION; ["Empty.1.opml", false, [[], []]], ["Empty.2.opml", false, [[], []]], ["Empty.3.opml", false, [[], []]], + ["FeedsOnly.opml", false, [[ + [ + 'url' => "http://example.com/1", + 'title' => "Feed 1", + 'folder' => 0, + 'tags' => [], + ], + [ + 'url' => "http://example.com/2", + 'title' => "", + 'folder' => 0, + 'tags' => [], + ], + [ + 'url' => "http://example.com/3", + 'title' => "", + 'folder' => 0, + 'tags' => [], + ], + [ + 'url' => "http://example.com/4", + 'title' => "", + 'folder' => 0, + 'tags' => [], + ], + [ + 'url' => "", + 'title' => "", + 'folder' => 0, + 'tags' => ["whee"], + ], + [ + 'url' => "", + 'title' => "", + 'folder' => 0, + 'tags' => ["whee", "whoo", ""], + ], + ], []]], ]; } } diff --git a/tests/docroot/Import/OPML/FeedsOnly.opml b/tests/docroot/Import/OPML/FeedsOnly.opml new file mode 100644 index 0000000..4e68260 --- /dev/null +++ b/tests/docroot/Import/OPML/FeedsOnly.opml @@ -0,0 +1,12 @@ + + + + + + + + + + + + From 644750487cc6e37dcf674198206a1107fee82922 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 6 May 2019 00:02:59 -0400 Subject: [PATCH 115/142] Command line documentation and fixes --- CHANGELOG | 4 ++ lib/CLI.php | 112 +++++++++++++++++++++++++++++++++--- tests/cases/CLI/TestCLI.php | 3 + 3 files changed, 111 insertions(+), 8 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index edc4b0a..c2ce336 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -5,6 +5,10 @@ New features: - Support for the Fever protocol (see README.md for details) - Command line functionality for clearing a password, disabling the account - Command line options for dealing with Fever passwords +- Command line documentation of all commands and options + +Bug fixes: +- Treat command line option -h the same as --help Version 0.7.1 (2019-03-25) ========================== diff --git a/lib/CLI.php b/lib/CLI.php index 5634ba6..7f22f94 100644 --- a/lib/CLI.php +++ b/lib/CLI.php @@ -24,16 +24,111 @@ Usage: arsse.php user unset-pass [--oldpass=] [--fever] arsse.php user auth [--fever] + arsse.php import [] + [-f | --flat] [-r | --replace] arsse.php export [] [-f | --flat] - arsse.php import [] - [-f | --flat] [-r | --replace] arsse.php --version - arsse.php --help | -h + arsse.php -h | --help + +The Arsse command-line interface can be used to perform various administrative +tasks such as starting the newsfeed refresh service, managing users, and +importing or exporting data. + +Commands: + + daemon + + Starts the newsfeed refreshing service, which will refresh stale feeds at + the configured interval automatically. + + feed refresh-all + + Refreshes any stale feeds once, then exits. This performs the same + function as the daemon command without looping; this is useful if use of + a scheduler such a cron is preferred over a persitent service. + + feed refresh + + Refreshes a single feed by numeric ID. This is principally for internal + use as the feed ID numbers are not usually exposed to the user. + + conf save-defaults [] + + Prints default configuration parameters to standard output, or to + if specified. Each parameter is annotated with a short description of its + purpose and usage. + + user [list] + + Prints a list of all existing users, one per line. + + user add [] + + Adds the user specified by , with the provided password + . If no password is specified, a random password will be + generated and printed to standard output. + + user remove + + Removes the user specified by . Data related to the user, + including folders and subscriptions, are immediately deleted. Feeds to + which the user was subscribed will be retained and refreshed until the + configured retention time elapses. + + user set-pass [] + + Changes 's password to . If not password is + specified, a random password will be generated and printed to standard + output. + + The --oldpass= option can be used to supply a user's exiting + password if this is required by the authentication driver to change a + password. Currently this is not used by any existing driver. + + The --fever option sets a user's Fever protocol password instead of their + general password. As Fever requires that passwords be stored insecurely, + users do not have Fever passwords by default, and logging in to the Fever + protocol is disabled until a password is set. It is highly recommended + that a user's Fever password be different from their general password. + + user unset-pass + + Unsets a user's password, effectively disabling their account. As with + password setting, the --oldpass and --fever options may be used. + + user auth + + Tests logging in as with password . This only checks + that the user's password is currectly recognized; it has no side effects. + + The --fever option may be used to test the user's Fever protocol password, + if any. + + import [] + + Imports the feeds, folders, and tags found in the OPML formatted + into the account of . If no file is specified, data is instead + read from standard input. + + The --replace option interprets the OPML file as the list of all desired + feeds, folders and tags, performing any deletion or moving of existing + entries which do not appear in the flle. If this option is not specified, + the file is assumed to list desired additions only. + + The --flat option can be used to ignore any folder structures in the file, + importing any feeds only into the root folder. + + export [] + + Exports 's feeds, folders, and tags to the OPML file specified + by , or standard output if none is provided. Note that due to a + limitation of the OPML format, any commas present in tag names will not be + retained in the export. -The Arsse command-line interface currently allows you to start the refresh -daemon, refresh all feeds or a specific feed by numeric ID, manage users, -or save default configuration to a sample file. + The --flat option can be used to omit folders from the export. Some OPML + implementations may not support folders, or arbitrary nesting; this option + may be used when planning to import into such software. USAGE_TEXT; protected function usage($prog): string { @@ -73,12 +168,13 @@ USAGE_TEXT; 'help' => false, ]); try { - $cmd = $this->command(["--help", "--version", "daemon", "feed refresh", "feed refresh-all", "conf save-defaults", "user", "export", "import"], $args); - if ($cmd && !in_array($cmd, ["--help", "--version", "conf save-defaults"])) { + $cmd = $this->command(["-h", "--help", "--version", "daemon", "feed refresh", "feed refresh-all", "conf save-defaults", "user", "export", "import"], $args); + if ($cmd && !in_array($cmd, ["-h", "--help", "--version", "conf save-defaults"])) { // only certain commands don't require configuration to be loaded $this->loadConf(); } switch ($cmd) { + case "-h": case "--help": echo $this->usage($argv0).\PHP_EOL; return 0; diff --git a/tests/cases/CLI/TestCLI.php b/tests/cases/CLI/TestCLI.php index 56202d9..46290fc 100644 --- a/tests/cases/CLI/TestCLI.php +++ b/tests/cases/CLI/TestCLI.php @@ -63,6 +63,9 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { ["arsse.php --help", "arsse.php"], ["arsse --help", "arsse"], ["thearsse --help", "thearsse"], + ["arsse.php -h", "arsse.php"], + ["arsse -h", "arsse"], + ["thearsse -h", "thearsse"], ]; } From 0f7d49c21e23d7cca9bba57c12111338f0d2a2f8 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 6 May 2019 19:36:39 -0400 Subject: [PATCH 116/142] More OPML tests and fixes --- lib/CLI.php | 5 ++-- lib/ImportExport/OPML.php | 23 +++++++++++++-- tests/cases/ImportExport/TestOPML.php | 33 ++++++++++++++++++++++ tests/docroot/Import/OPML/FoldersOnly.opml | 12 ++++++++ 4 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 tests/docroot/Import/OPML/FoldersOnly.opml diff --git a/lib/CLI.php b/lib/CLI.php index 7f22f94..0b1f3b9 100644 --- a/lib/CLI.php +++ b/lib/CLI.php @@ -78,9 +78,8 @@ Commands: user set-pass [] - Changes 's password to . If not password is - specified, a random password will be generated and printed to standard - output. + Changes 's password to . If no password is specified, + a random password will be generated and printed to standard output. The --oldpass= option can be used to supply a user's exiting password if this is required by the authentication driver to change a diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php index 7328413..a45d4e1 100644 --- a/lib/ImportExport/OPML.php +++ b/lib/ImportExport/OPML.php @@ -151,6 +151,23 @@ class OPML { throw new Exception("invalidSemantics", ['type' => "OPML"]); } $body = $body->item(0); + // function to find the next node in the tree + $next = function(\DOMNode $node, bool $visitChildren = true) use ($body) { + if ($visitChildren && $node->hasChildNodes()) { + return $node->firstChild; + } elseif ($node->nextSibling) { + return $node->nextSibling; + } else { + while (!$node->nextSibling && !$node->isSameNode($body)) { + $node = $node->parentNode; + } + if (!$node->isSameNode($body)) { + return $node->nextSibling; + } else { + return null; + } + } + }; $folders = []; $feeds = []; // add the root folder to a map from folder DOM nodes to folder ID numbers @@ -158,7 +175,7 @@ class OPML { $folderMap[$body] = sizeof($folderMap); // iterate through each node in the body $node = $body->firstChild; - while ($node && !$node->isSameNode($body)) { + while ($node) { if ($node->nodeType == \XML_ELEMENT_NODE && $node->nodeName === "outline") { // process any nodes which are outlines if ($node->getAttribute("type") === "rss") { @@ -187,11 +204,11 @@ class OPML { $folders[$id] = ['id' => $id, 'name' => $node->getAttribute("text"), 'parent' => $folderMap[$node->parentNode]]; } // proceed to child nodes, if any - $node = $node->hasChildNodes() ? $node->firstChild : ($node->nextSibling ?: $node->parentNode); + $node = $next($node); } } else { // skip any node which is not an outline element; if the node has descendents they are skipped as well - $node = $node->nextSibling ?: $node->parentNode; + $node = $next($node, false); } } return [$feeds, $folders]; diff --git a/tests/cases/ImportExport/TestOPML.php b/tests/cases/ImportExport/TestOPML.php index 94d1b05..59ea9c1 100644 --- a/tests/cases/ImportExport/TestOPML.php +++ b/tests/cases/ImportExport/TestOPML.php @@ -166,6 +166,39 @@ OPML_EXPORT_SERIALIZATION; 'tags' => ["whee", "whoo", ""], ], ], []]], + ["FoldersOnly.opml", true, [[], []]], + ["FoldersOnly.opml", false, [[], [1 => + [ + 'id' => 1, + 'name' => "Folder 1", + 'parent' => 0, + ], + [ + 'id' => 2, + 'name' => "Folder 2", + 'parent' => 0, + ], + [ + 'id' => 3, + 'name' => "Also a folder", + 'parent' => 2, + ], + [ + 'id' => 4, + 'name' => "Still a folder", + 'parent' => 2, + ], + [ + 'id' => 5, + 'name' => "Folder 5", + 'parent' => 4, + ], + [ + 'id' => 6, + 'name' => "Folder 6", + 'parent' => 0, + ], + ]]], ]; } } diff --git a/tests/docroot/Import/OPML/FoldersOnly.opml b/tests/docroot/Import/OPML/FoldersOnly.opml new file mode 100644 index 0000000..34b7a69 --- /dev/null +++ b/tests/docroot/Import/OPML/FoldersOnly.opml @@ -0,0 +1,12 @@ + + + + + + + + + + + + From be5a1fb94f3e27b2e3864e17ac2e592e394b0601 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 8 May 2019 20:24:16 -0400 Subject: [PATCH 117/142] Mixed content test for OPML --- tests/cases/ImportExport/TestOPML.php | 92 ++++++--------------- tests/docroot/Import/OPML/MixedContent.opml | 20 +++++ 2 files changed, 46 insertions(+), 66 deletions(-) create mode 100644 tests/docroot/Import/OPML/MixedContent.opml diff --git a/tests/cases/ImportExport/TestOPML.php b/tests/cases/ImportExport/TestOPML.php index 59ea9c1..0002f2c 100644 --- a/tests/cases/ImportExport/TestOPML.php +++ b/tests/cases/ImportExport/TestOPML.php @@ -129,75 +129,35 @@ OPML_EXPORT_SERIALIZATION; ["Empty.2.opml", false, [[], []]], ["Empty.3.opml", false, [[], []]], ["FeedsOnly.opml", false, [[ - [ - 'url' => "http://example.com/1", - 'title' => "Feed 1", - 'folder' => 0, - 'tags' => [], - ], - [ - 'url' => "http://example.com/2", - 'title' => "", - 'folder' => 0, - 'tags' => [], - ], - [ - 'url' => "http://example.com/3", - 'title' => "", - 'folder' => 0, - 'tags' => [], - ], - [ - 'url' => "http://example.com/4", - 'title' => "", - 'folder' => 0, - 'tags' => [], - ], - [ - 'url' => "", - 'title' => "", - 'folder' => 0, - 'tags' => ["whee"], - ], - [ - 'url' => "", - 'title' => "", - 'folder' => 0, - 'tags' => ["whee", "whoo", ""], - ], + ['url' => "http://example.com/1", 'title' => "Feed 1", 'folder' => 0, 'tags' => []], + ['url' => "http://example.com/2", 'title' => "", 'folder' => 0, 'tags' => []], + ['url' => "http://example.com/3", 'title' => "", 'folder' => 0, 'tags' => []], + ['url' => "http://example.com/4", 'title' => "", 'folder' => 0, 'tags' => []], + ['url' => "", 'title' => "", 'folder' => 0, 'tags' => ["whee"]], + ['url' => "", 'title' => "", 'folder' => 0, 'tags' => ["whee", "whoo", ""]], ], []]], ["FoldersOnly.opml", true, [[], []]], ["FoldersOnly.opml", false, [[], [1 => - [ - 'id' => 1, - 'name' => "Folder 1", - 'parent' => 0, - ], - [ - 'id' => 2, - 'name' => "Folder 2", - 'parent' => 0, - ], - [ - 'id' => 3, - 'name' => "Also a folder", - 'parent' => 2, - ], - [ - 'id' => 4, - 'name' => "Still a folder", - 'parent' => 2, - ], - [ - 'id' => 5, - 'name' => "Folder 5", - 'parent' => 4, - ], - [ - 'id' => 6, - 'name' => "Folder 6", - 'parent' => 0, - ], + ['id' => 1, 'name' => "Folder 1", 'parent' => 0], + ['id' => 2, 'name' => "Folder 2", 'parent' => 0], + ['id' => 3, 'name' => "Also a folder", 'parent' => 2], + ['id' => 4, 'name' => "Still a folder", 'parent' => 2], + ['id' => 5, 'name' => "Folder 5", 'parent' => 4], + ['id' => 6, 'name' => "Folder 6", 'parent' => 0], + ]]], + ["MixedContent.opml", false, [[ + ['url' => "https://www.jpl.nasa.gov/multimedia/rss/news.xml", 'title' => "NASA JPL", 'folder' => 3, 'tags' => ["tech"]], + ['url' => "http://feeds.arstechnica.com/arstechnica/index/", 'title' => "Ars Technica", 'folder' => 2, 'tags' => ["frequent", "tech"]], + ['url' => "https://www.thestar.com/content/thestar/feed.RSSManagerServlet.topstories.rss", 'title' => "Toronto Star", 'folder' => 5, 'tags' => ["news", "canada", "toronto"]], + ['url' => "http://rss.canada.com/get/?F239", 'title' => "Ottawa Citizen", 'folder' => 6, 'tags' => ["news", "canada"]], + ['url' => "https://www.eurogamer.net/?format=rss", 'title' => "Eurogamer", 'folder' => 0, 'tags' => ["gaming", "frequent"]], + ], [1 => + ['id' => 1, 'name' => "Photography", 'parent' => 0], + ['id' => 2, 'name' => "Science", 'parent' => 0], + ['id' => 3, 'name' => "Rocketry", 'parent' => 2], + ['id' => 4, 'name' => "Politics", 'parent' => 0], + ['id' => 5, 'name' => "Local", 'parent' => 4], + ['id' => 6, 'name' => "National", 'parent' => 4], ]]], ]; } diff --git a/tests/docroot/Import/OPML/MixedContent.opml b/tests/docroot/Import/OPML/MixedContent.opml new file mode 100644 index 0000000..db65a2c --- /dev/null +++ b/tests/docroot/Import/OPML/MixedContent.opml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + From c1e13e619944359f07245be24f7fc08b12c9c923 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sun, 12 May 2019 16:33:19 -0400 Subject: [PATCH 118/142] Tests for file imports --- lib/ImportExport/OPML.php | 24 +++++------ tests/cases/ImportExport/TestOPMLFile.php | 50 +++++++++++++++++++++++ 2 files changed, 62 insertions(+), 12 deletions(-) diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php index a45d4e1..5c633b4 100644 --- a/lib/ImportExport/OPML.php +++ b/lib/ImportExport/OPML.php @@ -38,9 +38,9 @@ class OPML { // start a transaction for atomic rollback $tr = Arsse::$db->begin(); // get current state of database - $foldersDb = iterator_to_array(Arsse::$db->folderList(Arsse::$user->id)); - $feedsDb = iterator_to_array(Arsse::$db->subscriptionList(Arsse::$user->id)); - $tagsDb = iterator_to_array(Arsse::$db->tagList(Arsse::$user->id)); + $foldersDb = iterator_to_array(Arsse::$db->folderList($user)); + $feedsDb = iterator_to_array(Arsse::$db->subscriptionList($user)); + $tagsDb = iterator_to_array(Arsse::$db->tagList($user)); // reconcile folders $folderMap = [0 => 0]; foreach ($folders as $id => $f) { @@ -54,7 +54,7 @@ class OPML { } if (!isset($folderMap[$id])) { // if no existing folder exists, add one - $folderMap[$id] = Arsse::$db->folderAdd(Arsse::$user->id, ['name' => $f['name'], 'parent' -> $parent]); + $folderMap[$id] = Arsse::$db->folderAdd($user, ['name' => $f['name'], 'parent' -> $parent]); } } // process newsfeed subscriptions @@ -74,11 +74,11 @@ class OPML { } if (!$found) { // if no subscription exists, add one - $feedMap[$f['id']] = Arsse::$db->subscriptionAdd(Arsse::$user->id, $f['url']); + $feedMap[$f['id']] = Arsse::$db->subscriptionAdd($user, $f['url']); } if (!$found || $replace) { // set the subscription's properties, if this is a new feed or we're doing a full replacement - Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $feedMap[$f['id']], ['title' => $title, 'folder' => $folder]); + Arsse::$db->subscriptionPropertiesSet($user, $feedMap[$f['id']], ['title' => $title, 'folder' => $folder]); // compile the set of used tags, if this is a new feed or we're doing a full replacement foreach ($f['tags'] as $t) { if (!strlen(trim($t))) { @@ -106,29 +106,29 @@ class OPML { } if (!$found) { // add the tag if it wasn't found - Arsse::$db->tagAdd(Arsse::$user->id, ['name' => $tag]); + Arsse::$db->tagAdd($user, ['name' => $tag]); } - Arsse::$db->tagSubscriptionsSet(Arsse::$user->id, $tag, $subs, $mode, true); + Arsse::$db->tagSubscriptionsSet($user, $tag, $subs, $mode, true); } // finally, if we're performing a replacement, delete any subscriptions, folders, or tags which were not present in the import if ($replace) { foreach (array_diff(array_column($feedsDb, "id"), $feedMap) as $id) { try { - Arsse::$db->subscriptionRemove(Arsse::$user->id, $id); + Arsse::$db->subscriptionRemove($user, $id); } catch (InputException $e) { // ignore errors } } foreach (array_diff(array_column($foldersDb, "id"), $folderMap) as $id) { try { - Arsse::$db->folderRemove(Arsse::$user->id, $id); + Arsse::$db->folderRemove($user, $id); } catch (InputException $e) { // ignore errors } } foreach (array_diff(array_column($tagsDb, "name"), array_keys($tagMap)) as $id) { try { - Arsse::$db->tagRemove(Arsse::$user->id, $id, true); + Arsse::$db->tagRemove($user, $id, true); } catch (InputException $e) { // ignore errors } @@ -287,7 +287,7 @@ class OPML { return true; } - public function imortFile(string $file, string $user, bool $flat = false, bool $replace): bool { + public function importFile(string $file, string $user, bool $flat = false, bool $replace): bool { $data = @file_get_contents($file); if ($data === false) { // if it fails throw an exception diff --git a/tests/cases/ImportExport/TestOPMLFile.php b/tests/cases/ImportExport/TestOPMLFile.php index 37b9e61..35147ef 100644 --- a/tests/cases/ImportExport/TestOPMLFile.php +++ b/tests/cases/ImportExport/TestOPMLFile.php @@ -21,16 +21,20 @@ class TestOPMLFile extends \JKingWeb\Arsse\Test\AbstractTest { // create a mock OPML processor with stubbed underlying import/export routines $this->opml = \Phake::partialMock(OPML::class); \Phake::when($this->opml)->export->thenReturn("OPML_FILE"); + \Phake::when($this->opml)->import->thenReturn(true); $this->vfs = vfsStream::setup("root", null, [ 'exportGoodFile' => "", 'exportGoodDir' => [], 'exportBadFile' => "", 'exportBadDir' => [], + 'importGoodFile' => "", + 'importBadFile' => "", ]); $this->path = $this->vfs->url()."/"; // make the "bad" entries inaccessible chmod($this->path."exportBadFile", 0000); chmod($this->path."exportBadDir", 0000); + chmod($this->path."importBadFile", 0000); } public function tearDown() { @@ -78,4 +82,50 @@ class TestOPMLFile extends \JKingWeb\Arsse\Test\AbstractTest { ["exportBadDir/file", "jane.doe@example.com", false, $createException], ]; } + + /** @dataProvider provideFileImports */ + public function testImportFromOpmlFile(string $file, string $user, bool $flat, bool $replace, $exp) { + $path = $this->path.$file; + try { + if ($exp instanceof \JKingWeb\Arsse\AbstractException) { + $this->assertException($exp); + $this->opml->importFile($path, $user, $flat, $replace); + } else { + $this->assertSame($exp, $this->opml->importFile($path, $user, $flat, $replace)); + } + } finally { + \Phake::verify($this->opml, \Phake::times((int) ($exp === true)))->import($user, "", $flat, $replace); + } + } + + public function provideFileImports() { + $missingException = new Exception("fileMissing"); + $permissionException = new Exception("fileUnreadable"); + return [ + ["importGoodFile", "john.doe@example.com", true, true, true], + ["importBadFile", "john.doe@example.com", true, true, $permissionException], + ["importNonFile", "john.doe@example.com", true, true, $missingException], + ["importGoodFile", "john.doe@example.com", true, false, true], + ["importBadFile", "john.doe@example.com", true, false, $permissionException], + ["importNonFile", "john.doe@example.com", true, false, $missingException], + ["importGoodFile", "john.doe@example.com", false, true, true], + ["importBadFile", "john.doe@example.com", false, true, $permissionException], + ["importNonFile", "john.doe@example.com", false, true, $missingException], + ["importGoodFile", "john.doe@example.com", false, false, true], + ["importBadFile", "john.doe@example.com", false, false, $permissionException], + ["importNonFile", "john.doe@example.com", false, false, $missingException], + ["importGoodFile", "jane.doe@example.com", true, true, true], + ["importBadFile", "jane.doe@example.com", true, true, $permissionException], + ["importNonFile", "jane.doe@example.com", true, true, $missingException], + ["importGoodFile", "jane.doe@example.com", true, false, true], + ["importBadFile", "jane.doe@example.com", true, false, $permissionException], + ["importNonFile", "jane.doe@example.com", true, false, $missingException], + ["importGoodFile", "jane.doe@example.com", false, true, true], + ["importBadFile", "jane.doe@example.com", false, true, $permissionException], + ["importNonFile", "jane.doe@example.com", false, true, $missingException], + ["importGoodFile", "jane.doe@example.com", false, false, true], + ["importBadFile", "jane.doe@example.com", false, false, $permissionException], + ["importNonFile", "jane.doe@example.com", false, false, $missingException], + ]; + } } From 54aaab50b5e0850d57491c3ad22eb4aa3c9d3b72 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 20 Jun 2019 15:57:49 -0400 Subject: [PATCH 119/142] Update tools --- vendor-bin/csfixer/composer.lock | 238 ++++++++++---------- vendor-bin/phpunit/composer.json | 2 +- vendor-bin/phpunit/composer.lock | 375 +++++++++++++++++-------------- vendor-bin/robo/composer.lock | 169 +++++++------- 4 files changed, 404 insertions(+), 380 deletions(-) diff --git a/vendor-bin/csfixer/composer.lock b/vendor-bin/csfixer/composer.lock index e48cd3d..2b7494e 100644 --- a/vendor-bin/csfixer/composer.lock +++ b/vendor-bin/csfixer/composer.lock @@ -70,16 +70,16 @@ }, { "name": "composer/xdebug-handler", - "version": "1.3.2", + "version": "1.3.3", "source": { "type": "git", "url": "https://github.com/composer/xdebug-handler.git", - "reference": "d17708133b6c276d6e42ef887a877866b909d892" + "reference": "46867cbf8ca9fb8d60c506895449eb799db1184f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/d17708133b6c276d6e42ef887a877866b909d892", - "reference": "d17708133b6c276d6e42ef887a877866b909d892", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/46867cbf8ca9fb8d60c506895449eb799db1184f", + "reference": "46867cbf8ca9fb8d60c506895449eb799db1184f", "shasum": "" }, "require": { @@ -110,34 +110,34 @@ "Xdebug", "performance" ], - "time": "2019-01-28T20:25:53+00:00" + "time": "2019-05-27T17:52:04+00:00" }, { "name": "doctrine/annotations", - "version": "v1.6.1", + "version": "v1.4.0", "source": { "type": "git", "url": "https://github.com/doctrine/annotations.git", - "reference": "53120e0eb10355388d6ccbe462f1fea34ddadb24" + "reference": "54cacc9b81758b14e3ce750f205a393d52339e97" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/annotations/zipball/53120e0eb10355388d6ccbe462f1fea34ddadb24", - "reference": "53120e0eb10355388d6ccbe462f1fea34ddadb24", + "url": "https://api.github.com/repos/doctrine/annotations/zipball/54cacc9b81758b14e3ce750f205a393d52339e97", + "reference": "54cacc9b81758b14e3ce750f205a393d52339e97", "shasum": "" }, "require": { "doctrine/lexer": "1.*", - "php": "^7.1" + "php": "^5.6 || ^7.0" }, "require-dev": { "doctrine/cache": "1.*", - "phpunit/phpunit": "^6.4" + "phpunit/phpunit": "^5.7" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.6.x-dev" + "dev-master": "1.4.x-dev" } }, "autoload": { @@ -178,25 +178,28 @@ "docblock", "parser" ], - "time": "2019-03-25T19:12:02+00:00" + "time": "2017-02-24T16:22:25+00:00" }, { "name": "doctrine/lexer", - "version": "v1.0.1", + "version": "1.0.2", "source": { "type": "git", "url": "https://github.com/doctrine/lexer.git", - "reference": "83893c552fd2045dd78aef794c31e694c37c0b8c" + "reference": "1febd6c3ef84253d7c815bed85fc622ad207a9f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/lexer/zipball/83893c552fd2045dd78aef794c31e694c37c0b8c", - "reference": "83893c552fd2045dd78aef794c31e694c37c0b8c", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/1febd6c3ef84253d7c815bed85fc622ad207a9f8", + "reference": "1febd6c3ef84253d7c815bed85fc622ad207a9f8", "shasum": "" }, "require": { "php": ">=5.3.2" }, + "require-dev": { + "phpunit/phpunit": "^4.5" + }, "type": "library", "extra": { "branch-alias": { @@ -204,8 +207,8 @@ } }, "autoload": { - "psr-0": { - "Doctrine\\Common\\Lexer\\": "lib/" + "psr-4": { + "Doctrine\\Common\\Lexer\\": "lib/Doctrine/Common/Lexer" } }, "notification-url": "https://packagist.org/downloads/", @@ -226,26 +229,29 @@ "email": "schmittjoh@gmail.com" } ], - "description": "Base library for a lexer that can be used in Top-Down, Recursive Descent Parsers.", - "homepage": "http://www.doctrine-project.org", + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", "keywords": [ + "annotations", + "docblock", "lexer", - "parser" + "parser", + "php" ], - "time": "2014-09-09T13:34:57+00:00" + "time": "2019-06-08T11:03:04+00:00" }, { "name": "friendsofphp/php-cs-fixer", - "version": "v2.14.2", + "version": "v2.15.1", "source": { "type": "git", "url": "https://github.com/FriendsOfPHP/PHP-CS-Fixer.git", - "reference": "ff401e58261ffc5934a58f795b3f95b355e276cb" + "reference": "20064511ab796593a3990669eff5f5b535001f7c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/ff401e58261ffc5934a58f795b3f95b355e276cb", - "reference": "ff401e58261ffc5934a58f795b3f95b355e276cb", + "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/20064511ab796593a3990669eff5f5b535001f7c", + "reference": "20064511ab796593a3990669eff5f5b535001f7c", "shasum": "" }, "require": { @@ -273,11 +279,11 @@ "mikey179/vfsstream": "^1.6", "php-coveralls/php-coveralls": "^2.1", "php-cs-fixer/accessible-object": "^1.0", - "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.0.1", - "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.0.1", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.1", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.1", "phpunit/phpunit": "^5.7.27 || ^6.5.8 || ^7.1", - "phpunitgoodpractices/traits": "^1.5.1", - "symfony/phpunit-bridge": "^4.0" + "phpunitgoodpractices/traits": "^1.8", + "symfony/phpunit-bridge": "^4.3" }, "suggest": { "ext-mbstring": "For handling non-UTF8 characters in cache signature.", @@ -320,7 +326,7 @@ } ], "description": "A tool to automatically fix PHP code style", - "time": "2019-02-17T17:44:13+00:00" + "time": "2019-06-01T10:32:12+00:00" }, { "name": "paragonie/random_compat", @@ -467,21 +473,21 @@ }, { "name": "symfony/console", - "version": "v4.2.4", + "version": "v3.4.28", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "9dc2299a016497f9ee620be94524e6c0af0280a9" + "reference": "8e1d1e406dd31727fa70cd5a99cda202e9d6a5c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/9dc2299a016497f9ee620be94524e6c0af0280a9", - "reference": "9dc2299a016497f9ee620be94524e6c0af0280a9", + "url": "https://api.github.com/repos/symfony/console/zipball/8e1d1e406dd31727fa70cd5a99cda202e9d6a5c6", + "reference": "8e1d1e406dd31727fa70cd5a99cda202e9d6a5c6", "shasum": "" }, "require": { - "php": "^7.1.3", - "symfony/contracts": "^1.0", + "php": "^5.5.9|>=7.0.8", + "symfony/debug": "~2.8|~3.0|~4.0", "symfony/polyfill-mbstring": "~1.0" }, "conflict": { @@ -493,11 +499,11 @@ }, "require-dev": { "psr/log": "~1.0", - "symfony/config": "~3.4|~4.0", + "symfony/config": "~3.3|~4.0", "symfony/dependency-injection": "~3.4|~4.0", - "symfony/event-dispatcher": "~3.4|~4.0", + "symfony/event-dispatcher": "~2.8|~3.0|~4.0", "symfony/lock": "~3.4|~4.0", - "symfony/process": "~3.4|~4.0" + "symfony/process": "~3.3|~4.0" }, "suggest": { "psr/log": "For using the console logger", @@ -508,7 +514,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "3.4-dev" } }, "autoload": { @@ -535,48 +541,44 @@ ], "description": "Symfony Console Component", "homepage": "https://symfony.com", - "time": "2019-02-23T15:17:42+00:00" + "time": "2019-05-09T08:42:51+00:00" }, { - "name": "symfony/contracts", - "version": "v1.0.2", + "name": "symfony/debug", + "version": "v3.4.28", "source": { "type": "git", - "url": "https://github.com/symfony/contracts.git", - "reference": "1aa7ab2429c3d594dd70689604b5cf7421254cdf" + "url": "https://github.com/symfony/debug.git", + "reference": "671fc55bd14800668b1d0a3708c3714940e30a8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/contracts/zipball/1aa7ab2429c3d594dd70689604b5cf7421254cdf", - "reference": "1aa7ab2429c3d594dd70689604b5cf7421254cdf", + "url": "https://api.github.com/repos/symfony/debug/zipball/671fc55bd14800668b1d0a3708c3714940e30a8c", + "reference": "671fc55bd14800668b1d0a3708c3714940e30a8c", "shasum": "" }, "require": { - "php": "^7.1.3" + "php": "^5.5.9|>=7.0.8", + "psr/log": "~1.0" }, - "require-dev": { - "psr/cache": "^1.0", - "psr/container": "^1.0" + "conflict": { + "symfony/http-kernel": ">=2.3,<2.3.24|~2.4.0|>=2.5,<2.5.9|>=2.6,<2.6.2" }, - "suggest": { - "psr/cache": "When using the Cache contracts", - "psr/container": "When using the Service contracts", - "symfony/cache-contracts-implementation": "", - "symfony/service-contracts-implementation": "", - "symfony/translation-contracts-implementation": "" + "require-dev": { + "symfony/http-kernel": "~2.8|~3.0|~4.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-master": "3.4-dev" } }, "autoload": { "psr-4": { - "Symfony\\Contracts\\": "" + "Symfony\\Component\\Debug\\": "" }, "exclude-from-classmap": [ - "**/Tests/" + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -585,53 +587,44 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "A set of abstractions extracted out of the Symfony components", + "description": "Symfony Debug Component", "homepage": "https://symfony.com", - "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" - ], - "time": "2018-12-05T08:06:11+00:00" + "time": "2019-05-18T13:32:47+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v4.2.4", + "version": "v3.4.28", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "3354d2e6af986dd71f68b4e5cf4a933ab58697fb" + "reference": "a088aafcefb4eef2520a290ed82e4374092a6dff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/3354d2e6af986dd71f68b4e5cf4a933ab58697fb", - "reference": "3354d2e6af986dd71f68b4e5cf4a933ab58697fb", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/a088aafcefb4eef2520a290ed82e4374092a6dff", + "reference": "a088aafcefb4eef2520a290ed82e4374092a6dff", "shasum": "" }, "require": { - "php": "^7.1.3", - "symfony/contracts": "^1.0" + "php": "^5.5.9|>=7.0.8" }, "conflict": { - "symfony/dependency-injection": "<3.4" + "symfony/dependency-injection": "<3.3" }, "require-dev": { "psr/log": "~1.0", - "symfony/config": "~3.4|~4.0", - "symfony/dependency-injection": "~3.4|~4.0", - "symfony/expression-language": "~3.4|~4.0", - "symfony/stopwatch": "~3.4|~4.0" + "symfony/config": "~2.8|~3.0|~4.0", + "symfony/dependency-injection": "~3.3|~4.0", + "symfony/expression-language": "~2.8|~3.0|~4.0", + "symfony/stopwatch": "~2.8|~3.0|~4.0" }, "suggest": { "symfony/dependency-injection": "", @@ -640,7 +633,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "3.4-dev" } }, "autoload": { @@ -667,30 +660,30 @@ ], "description": "Symfony EventDispatcher Component", "homepage": "https://symfony.com", - "time": "2019-02-23T15:17:42+00:00" + "time": "2019-04-02T08:51:52+00:00" }, { "name": "symfony/filesystem", - "version": "v4.2.4", + "version": "v3.4.28", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "e16b9e471703b2c60b95f14d31c1239f68f11601" + "reference": "acf99758b1df8e9295e6b85aa69f294565c9fedb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/e16b9e471703b2c60b95f14d31c1239f68f11601", - "reference": "e16b9e471703b2c60b95f14d31c1239f68f11601", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/acf99758b1df8e9295e6b85aa69f294565c9fedb", + "reference": "acf99758b1df8e9295e6b85aa69f294565c9fedb", "shasum": "" }, "require": { - "php": "^7.1.3", + "php": "^5.5.9|>=7.0.8", "symfony/polyfill-ctype": "~1.8" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "3.4-dev" } }, "autoload": { @@ -717,29 +710,29 @@ ], "description": "Symfony Filesystem Component", "homepage": "https://symfony.com", - "time": "2019-02-07T11:40:08+00:00" + "time": "2019-02-04T21:34:32+00:00" }, { "name": "symfony/finder", - "version": "v4.2.4", + "version": "v3.4.28", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "267b7002c1b70ea80db0833c3afe05f0fbde580a" + "reference": "fa5d962a71f2169dfe1cbae217fa5a2799859f6c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/267b7002c1b70ea80db0833c3afe05f0fbde580a", - "reference": "267b7002c1b70ea80db0833c3afe05f0fbde580a", + "url": "https://api.github.com/repos/symfony/finder/zipball/fa5d962a71f2169dfe1cbae217fa5a2799859f6c", + "reference": "fa5d962a71f2169dfe1cbae217fa5a2799859f6c", "shasum": "" }, "require": { - "php": "^7.1.3" + "php": "^5.5.9|>=7.0.8" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "3.4-dev" } }, "autoload": { @@ -766,29 +759,29 @@ ], "description": "Symfony Finder Component", "homepage": "https://symfony.com", - "time": "2019-02-23T15:42:05+00:00" + "time": "2019-05-24T12:25:55+00:00" }, { "name": "symfony/options-resolver", - "version": "v4.2.4", + "version": "v3.4.28", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "3896e5a7d06fd15fa4947694c8dcdd371ff147d1" + "reference": "ed3b397f9c07c8ca388b2a1ef744403b4d4ecc44" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/3896e5a7d06fd15fa4947694c8dcdd371ff147d1", - "reference": "3896e5a7d06fd15fa4947694c8dcdd371ff147d1", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/ed3b397f9c07c8ca388b2a1ef744403b4d4ecc44", + "reference": "ed3b397f9c07c8ca388b2a1ef744403b4d4ecc44", "shasum": "" }, "require": { - "php": "^7.1.3" + "php": "^5.5.9|>=7.0.8" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "3.4-dev" } }, "autoload": { @@ -820,7 +813,7 @@ "configuration", "options" ], - "time": "2019-02-23T15:17:42+00:00" + "time": "2019-04-10T16:00:48+00:00" }, { "name": "symfony/polyfill-ctype", @@ -1055,25 +1048,25 @@ }, { "name": "symfony/process", - "version": "v4.2.4", + "version": "v3.4.28", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "6c05edb11fbeff9e2b324b4270ecb17911a8b7ad" + "reference": "afe411c2a6084f25cff55a01d0d4e1474c97ff13" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/6c05edb11fbeff9e2b324b4270ecb17911a8b7ad", - "reference": "6c05edb11fbeff9e2b324b4270ecb17911a8b7ad", + "url": "https://api.github.com/repos/symfony/process/zipball/afe411c2a6084f25cff55a01d0d4e1474c97ff13", + "reference": "afe411c2a6084f25cff55a01d0d4e1474c97ff13", "shasum": "" }, "require": { - "php": "^7.1.3" + "php": "^5.5.9|>=7.0.8" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "3.4-dev" } }, "autoload": { @@ -1100,30 +1093,29 @@ ], "description": "Symfony Process Component", "homepage": "https://symfony.com", - "time": "2019-01-24T22:05:03+00:00" + "time": "2019-05-22T12:54:11+00:00" }, { "name": "symfony/stopwatch", - "version": "v4.2.4", + "version": "v3.4.28", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "b1a5f646d56a3290230dbc8edf2a0d62cda23f67" + "reference": "2a651c2645c10bbedd21170771f122d935e0dd58" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/b1a5f646d56a3290230dbc8edf2a0d62cda23f67", - "reference": "b1a5f646d56a3290230dbc8edf2a0d62cda23f67", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/2a651c2645c10bbedd21170771f122d935e0dd58", + "reference": "2a651c2645c10bbedd21170771f122d935e0dd58", "shasum": "" }, "require": { - "php": "^7.1.3", - "symfony/contracts": "^1.0" + "php": "^5.5.9|>=7.0.8" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "3.4-dev" } }, "autoload": { @@ -1150,7 +1142,7 @@ ], "description": "Symfony Stopwatch Component", "homepage": "https://symfony.com", - "time": "2019-01-16T20:31:39+00:00" + "time": "2019-01-16T09:39:14+00:00" } ], "packages-dev": [], diff --git a/vendor-bin/phpunit/composer.json b/vendor-bin/phpunit/composer.json index d6c1f86..3afc6e7 100644 --- a/vendor-bin/phpunit/composer.json +++ b/vendor-bin/phpunit/composer.json @@ -1,6 +1,6 @@ { "require": { - "phpunit/phpunit": "7.*", + "phpunit/phpunit": "6.* | 7.*", "phake/phake": "^3.0", "clue/arguments": "^2.0", "mikey179/vfsStream": "^1.6", diff --git a/vendor-bin/phpunit/composer.lock b/vendor-bin/phpunit/composer.lock index 32cf644..3f6a3a4 100644 --- a/vendor-bin/phpunit/composer.lock +++ b/vendor-bin/phpunit/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e69de7425d904e9dadfed81536ecd712", + "content-hash": "57ab1526d0611e8766f67b8e0fa16912", "packages": [ { "name": "clue/arguments", @@ -58,34 +58,32 @@ }, { "name": "doctrine/instantiator", - "version": "1.2.0", + "version": "1.0.5", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "a2c590166b2133a4633738648b6b064edae0814a" + "reference": "8e884e78f9f0eb1329e445619e04456e64d8051d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/a2c590166b2133a4633738648b6b064edae0814a", - "reference": "a2c590166b2133a4633738648b6b064edae0814a", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/8e884e78f9f0eb1329e445619e04456e64d8051d", + "reference": "8e884e78f9f0eb1329e445619e04456e64d8051d", "shasum": "" }, "require": { - "php": "^7.1" + "php": ">=5.3,<8.0-DEV" }, "require-dev": { - "doctrine/coding-standard": "^6.0", + "athletic/athletic": "~0.1.8", "ext-pdo": "*", "ext-phar": "*", - "phpbench/phpbench": "^0.13", - "phpstan/phpstan-phpunit": "^0.11", - "phpstan/phpstan-shim": "^0.11", - "phpunit/phpunit": "^7.0" + "phpunit/phpunit": "~4.0", + "squizlabs/php_codesniffer": "~2.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.2.x-dev" + "dev-master": "1.0.x-dev" } }, "autoload": { @@ -105,25 +103,25 @@ } ], "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", - "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "homepage": "https://github.com/doctrine/instantiator", "keywords": [ "constructor", "instantiate" ], - "time": "2019-03-17T17:37:11+00:00" + "time": "2015-06-14T21:17:01+00:00" }, { - "name": "mikey179/vfsStream", - "version": "v1.6.5", + "name": "mikey179/vfsstream", + "version": "v1.6.6", "source": { "type": "git", - "url": "https://github.com/mikey179/vfsStream.git", - "reference": "d5fec95f541d4d71c4823bb5e30cf9b9e5b96145" + "url": "https://github.com/bovigo/vfsStream.git", + "reference": "095238a0711c974ae5b4ebf4c4534a23f3f6c99d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mikey179/vfsStream/zipball/d5fec95f541d4d71c4823bb5e30cf9b9e5b96145", - "reference": "d5fec95f541d4d71c4823bb5e30cf9b9e5b96145", + "url": "https://api.github.com/repos/bovigo/vfsStream/zipball/095238a0711c974ae5b4ebf4c4534a23f3f6c99d", + "reference": "095238a0711c974ae5b4ebf4c4534a23f3f6c99d", "shasum": "" }, "require": { @@ -156,32 +154,29 @@ ], "description": "Virtual file system to mock the real file system in unit tests.", "homepage": "http://vfs.bovigo.org/", - "time": "2017-08-01T08:02:14+00:00" + "time": "2019-04-08T13:54:32+00:00" }, { "name": "myclabs/deep-copy", - "version": "1.8.1", + "version": "1.7.0", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "3e01bdad3e18354c3dce54466b7fbe33a9f9f7f8" + "reference": "3b8a3a99ba1f6a3952ac2747d989303cbd6b7a3e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/3e01bdad3e18354c3dce54466b7fbe33a9f9f7f8", - "reference": "3e01bdad3e18354c3dce54466b7fbe33a9f9f7f8", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/3b8a3a99ba1f6a3952ac2747d989303cbd6b7a3e", + "reference": "3b8a3a99ba1f6a3952ac2747d989303cbd6b7a3e", "shasum": "" }, "require": { - "php": "^7.1" - }, - "replace": { - "myclabs/deep-copy": "self.version" + "php": "^5.6 || ^7.0" }, "require-dev": { "doctrine/collections": "^1.0", "doctrine/common": "^2.6", - "phpunit/phpunit": "^7.1" + "phpunit/phpunit": "^4.1" }, "type": "library", "autoload": { @@ -204,20 +199,20 @@ "object", "object graph" ], - "time": "2018-06-11T23:09:50+00:00" + "time": "2017-10-19T19:58:43+00:00" }, { "name": "phake/phake", - "version": "v3.1.3", + "version": "v3.1.6", "source": { "type": "git", "url": "https://github.com/mlively/Phake.git", - "reference": "5208167c10f3c0b8e87066d6d5b41e6b754bd4d4" + "reference": "3848901ed8e236534ae684dd5cf0f3bfc4c8a24c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mlively/Phake/zipball/5208167c10f3c0b8e87066d6d5b41e6b754bd4d4", - "reference": "5208167c10f3c0b8e87066d6d5b41e6b754bd4d4", + "url": "https://api.github.com/repos/mlively/Phake/zipball/3848901ed8e236534ae684dd5cf0f3bfc4c8a24c", + "reference": "3848901ed8e236534ae684dd5cf0f3bfc4c8a24c", "shasum": "" }, "require": { @@ -262,26 +257,26 @@ "mock", "testing" ], - "time": "2018-08-04T00:42:49+00:00" + "time": "2019-06-06T22:41:35+00:00" }, { "name": "phar-io/manifest", - "version": "1.0.3", + "version": "1.0.1", "source": { "type": "git", "url": "https://github.com/phar-io/manifest.git", - "reference": "7761fcacf03b4d4f16e7ccb606d4879ca431fcf4" + "reference": "2df402786ab5368a0169091f61a7c1e0eb6852d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/manifest/zipball/7761fcacf03b4d4f16e7ccb606d4879ca431fcf4", - "reference": "7761fcacf03b4d4f16e7ccb606d4879ca431fcf4", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/2df402786ab5368a0169091f61a7c1e0eb6852d0", + "reference": "2df402786ab5368a0169091f61a7c1e0eb6852d0", "shasum": "" }, "require": { "ext-dom": "*", "ext-phar": "*", - "phar-io/version": "^2.0", + "phar-io/version": "^1.0.1", "php": "^5.6 || ^7.0" }, "type": "library", @@ -317,20 +312,20 @@ } ], "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", - "time": "2018-07-08T19:23:20+00:00" + "time": "2017-03-05T18:14:27+00:00" }, { "name": "phar-io/version", - "version": "2.0.1", + "version": "1.0.1", "source": { "type": "git", "url": "https://github.com/phar-io/version.git", - "reference": "45a2ec53a73c70ce41d55cedef9063630abaf1b6" + "reference": "a70c0ced4be299a63d32fa96d9281d03e94041df" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/version/zipball/45a2ec53a73c70ce41d55cedef9063630abaf1b6", - "reference": "45a2ec53a73c70ce41d55cedef9063630abaf1b6", + "url": "https://api.github.com/repos/phar-io/version/zipball/a70c0ced4be299a63d32fa96d9281d03e94041df", + "reference": "a70c0ced4be299a63d32fa96d9281d03e94041df", "shasum": "" }, "require": { @@ -364,7 +359,7 @@ } ], "description": "Library for handling version information and constraints", - "time": "2018-07-08T19:19:57+00:00" + "time": "2017-03-05T17:38:23+00:00" }, { "name": "phpdocumentor/reflection-common", @@ -422,16 +417,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "4.3.0", + "version": "4.3.1", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "94fd0001232e47129dd3504189fa1c7225010d08" + "reference": "bdd9f737ebc2a01c06ea7ff4308ec6697db9b53c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/94fd0001232e47129dd3504189fa1c7225010d08", - "reference": "94fd0001232e47129dd3504189fa1c7225010d08", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/bdd9f737ebc2a01c06ea7ff4308ec6697db9b53c", + "reference": "bdd9f737ebc2a01c06ea7ff4308ec6697db9b53c", "shasum": "" }, "require": { @@ -469,7 +464,7 @@ } ], "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", - "time": "2017-11-30T07:14:17+00:00" + "time": "2019-04-30T17:48:53+00:00" }, { "name": "phpdocumentor/type-resolver", @@ -520,16 +515,16 @@ }, { "name": "phpspec/prophecy", - "version": "1.8.0", + "version": "1.8.1", "source": { "type": "git", "url": "https://github.com/phpspec/prophecy.git", - "reference": "4ba436b55987b4bf311cb7c6ba82aa528aac0a06" + "reference": "1927e75f4ed19131ec9bcc3b002e07fb1173ee76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpspec/prophecy/zipball/4ba436b55987b4bf311cb7c6ba82aa528aac0a06", - "reference": "4ba436b55987b4bf311cb7c6ba82aa528aac0a06", + "url": "https://api.github.com/repos/phpspec/prophecy/zipball/1927e75f4ed19131ec9bcc3b002e07fb1173ee76", + "reference": "1927e75f4ed19131ec9bcc3b002e07fb1173ee76", "shasum": "" }, "require": { @@ -550,8 +545,8 @@ } }, "autoload": { - "psr-0": { - "Prophecy\\": "src/" + "psr-4": { + "Prophecy\\": "src/Prophecy" } }, "notification-url": "https://packagist.org/downloads/", @@ -579,44 +574,44 @@ "spy", "stub" ], - "time": "2018-08-05T17:53:17+00:00" + "time": "2019-06-13T12:50:23+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "6.1.4", + "version": "5.3.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "807e6013b00af69b6c5d9ceb4282d0393dbb9d8d" + "reference": "c89677919c5dd6d3b3852f230a663118762218ac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/807e6013b00af69b6c5d9ceb4282d0393dbb9d8d", - "reference": "807e6013b00af69b6c5d9ceb4282d0393dbb9d8d", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/c89677919c5dd6d3b3852f230a663118762218ac", + "reference": "c89677919c5dd6d3b3852f230a663118762218ac", "shasum": "" }, "require": { "ext-dom": "*", "ext-xmlwriter": "*", - "php": "^7.1", - "phpunit/php-file-iterator": "^2.0", + "php": "^7.0", + "phpunit/php-file-iterator": "^1.4.2", "phpunit/php-text-template": "^1.2.1", - "phpunit/php-token-stream": "^3.0", + "phpunit/php-token-stream": "^2.0.1", "sebastian/code-unit-reverse-lookup": "^1.0.1", - "sebastian/environment": "^3.1 || ^4.0", + "sebastian/environment": "^3.0", "sebastian/version": "^2.0.1", "theseer/tokenizer": "^1.1" }, "require-dev": { - "phpunit/phpunit": "^7.0" + "phpunit/phpunit": "^6.0" }, "suggest": { - "ext-xdebug": "^2.6.0" + "ext-xdebug": "^2.5.5" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "6.1-dev" + "dev-master": "5.3.x-dev" } }, "autoload": { @@ -642,32 +637,29 @@ "testing", "xunit" ], - "time": "2018-10-31T16:06:48+00:00" + "time": "2018-04-06T15:36:58+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "2.0.2", + "version": "1.4.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "050bedf145a257b1ff02746c31894800e5122946" + "reference": "730b01bc3e867237eaac355e06a36b85dd93a8b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/050bedf145a257b1ff02746c31894800e5122946", - "reference": "050bedf145a257b1ff02746c31894800e5122946", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/730b01bc3e867237eaac355e06a36b85dd93a8b4", + "reference": "730b01bc3e867237eaac355e06a36b85dd93a8b4", "shasum": "" }, "require": { - "php": "^7.1" - }, - "require-dev": { - "phpunit/phpunit": "^7.1" + "php": ">=5.3.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-master": "1.4.x-dev" } }, "autoload": { @@ -682,7 +674,7 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", + "email": "sb@sebastian-bergmann.de", "role": "lead" } ], @@ -692,7 +684,7 @@ "filesystem", "iterator" ], - "time": "2018-09-13T20:33:42+00:00" + "time": "2017-11-27T13:52:08+00:00" }, { "name": "phpunit/php-text-template", @@ -737,28 +729,28 @@ }, { "name": "phpunit/php-timer", - "version": "2.1.1", + "version": "1.0.9", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "8b389aebe1b8b0578430bda0c7c95a829608e059" + "reference": "3dcf38ca72b158baf0bc245e9184d3fdffa9c46f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/8b389aebe1b8b0578430bda0c7c95a829608e059", - "reference": "8b389aebe1b8b0578430bda0c7c95a829608e059", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3dcf38ca72b158baf0bc245e9184d3fdffa9c46f", + "reference": "3dcf38ca72b158baf0bc245e9184d3fdffa9c46f", "shasum": "" }, "require": { - "php": "^7.1" + "php": "^5.3.3 || ^7.0" }, "require-dev": { - "phpunit/phpunit": "^7.0" + "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.1-dev" + "dev-master": "1.0-dev" } }, "autoload": { @@ -773,7 +765,7 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", + "email": "sb@sebastian-bergmann.de", "role": "lead" } ], @@ -782,33 +774,33 @@ "keywords": [ "timer" ], - "time": "2019-02-20T10:12:59+00:00" + "time": "2017-02-26T11:10:40+00:00" }, { "name": "phpunit/php-token-stream", - "version": "3.0.1", + "version": "2.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-token-stream.git", - "reference": "c99e3be9d3e85f60646f152f9002d46ed7770d18" + "reference": "791198a2c6254db10131eecfe8c06670700904db" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/c99e3be9d3e85f60646f152f9002d46ed7770d18", - "reference": "c99e3be9d3e85f60646f152f9002d46ed7770d18", + "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/791198a2c6254db10131eecfe8c06670700904db", + "reference": "791198a2c6254db10131eecfe8c06670700904db", "shasum": "" }, "require": { "ext-tokenizer": "*", - "php": "^7.1" + "php": "^7.0" }, "require-dev": { - "phpunit/phpunit": "^7.0" + "phpunit/phpunit": "^6.2.4" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-master": "2.0-dev" } }, "autoload": { @@ -831,57 +823,57 @@ "keywords": [ "tokenizer" ], - "time": "2018-10-30T05:52:18+00:00" + "time": "2017-11-27T05:48:46+00:00" }, { "name": "phpunit/phpunit", - "version": "7.5.8", + "version": "6.5.14", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "c29c0525cf4572c11efe1db49a8b8aee9dfac58a" + "reference": "bac23fe7ff13dbdb461481f706f0e9fe746334b7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c29c0525cf4572c11efe1db49a8b8aee9dfac58a", - "reference": "c29c0525cf4572c11efe1db49a8b8aee9dfac58a", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/bac23fe7ff13dbdb461481f706f0e9fe746334b7", + "reference": "bac23fe7ff13dbdb461481f706f0e9fe746334b7", "shasum": "" }, "require": { - "doctrine/instantiator": "^1.1", "ext-dom": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", "ext-xml": "*", - "myclabs/deep-copy": "^1.7", - "phar-io/manifest": "^1.0.2", - "phar-io/version": "^2.0", - "php": "^7.1", + "myclabs/deep-copy": "^1.6.1", + "phar-io/manifest": "^1.0.1", + "phar-io/version": "^1.0", + "php": "^7.0", "phpspec/prophecy": "^1.7", - "phpunit/php-code-coverage": "^6.0.7", - "phpunit/php-file-iterator": "^2.0.1", + "phpunit/php-code-coverage": "^5.3", + "phpunit/php-file-iterator": "^1.4.3", "phpunit/php-text-template": "^1.2.1", - "phpunit/php-timer": "^2.1", - "sebastian/comparator": "^3.0", - "sebastian/diff": "^3.0", - "sebastian/environment": "^4.0", + "phpunit/php-timer": "^1.0.9", + "phpunit/phpunit-mock-objects": "^5.0.9", + "sebastian/comparator": "^2.1", + "sebastian/diff": "^2.0", + "sebastian/environment": "^3.1", "sebastian/exporter": "^3.1", "sebastian/global-state": "^2.0", "sebastian/object-enumerator": "^3.0.3", - "sebastian/resource-operations": "^2.0", + "sebastian/resource-operations": "^1.0", "sebastian/version": "^2.0.1" }, "conflict": { - "phpunit/phpunit-mock-objects": "*" + "phpdocumentor/reflection-docblock": "3.0.2", + "phpunit/dbunit": "<3.0" }, "require-dev": { "ext-pdo": "*" }, "suggest": { - "ext-soap": "*", "ext-xdebug": "*", - "phpunit/php-invoker": "^2.0" + "phpunit/php-invoker": "^1.1" }, "bin": [ "phpunit" @@ -889,7 +881,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "7.5-dev" + "dev-master": "6.5.x-dev" } }, "autoload": { @@ -915,7 +907,67 @@ "testing", "xunit" ], - "time": "2019-03-26T13:23:54+00:00" + "time": "2019-02-01T05:22:47+00:00" + }, + { + "name": "phpunit/phpunit-mock-objects", + "version": "5.0.10", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit-mock-objects.git", + "reference": "cd1cf05c553ecfec36b170070573e540b67d3f1f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/cd1cf05c553ecfec36b170070573e540b67d3f1f", + "reference": "cd1cf05c553ecfec36b170070573e540b67d3f1f", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.0.5", + "php": "^7.0", + "phpunit/php-text-template": "^1.2.1", + "sebastian/exporter": "^3.1" + }, + "conflict": { + "phpunit/phpunit": "<6.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.5.11" + }, + "suggest": { + "ext-soap": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Mock Object library for PHPUnit", + "homepage": "https://github.com/sebastianbergmann/phpunit-mock-objects/", + "keywords": [ + "mock", + "xunit" + ], + "abandoned": true, + "time": "2018-08-09T05:50:03+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", @@ -964,30 +1016,30 @@ }, { "name": "sebastian/comparator", - "version": "3.0.2", + "version": "2.1.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "5de4fc177adf9bce8df98d8d141a7559d7ccf6da" + "reference": "34369daee48eafb2651bea869b4b15d75ccc35f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/5de4fc177adf9bce8df98d8d141a7559d7ccf6da", - "reference": "5de4fc177adf9bce8df98d8d141a7559d7ccf6da", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/34369daee48eafb2651bea869b4b15d75ccc35f9", + "reference": "34369daee48eafb2651bea869b4b15d75ccc35f9", "shasum": "" }, "require": { - "php": "^7.1", - "sebastian/diff": "^3.0", + "php": "^7.0", + "sebastian/diff": "^2.0 || ^3.0", "sebastian/exporter": "^3.1" }, "require-dev": { - "phpunit/phpunit": "^7.1" + "phpunit/phpunit": "^6.4" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-master": "2.1.x-dev" } }, "autoload": { @@ -1024,33 +1076,32 @@ "compare", "equality" ], - "time": "2018-07-12T15:12:46+00:00" + "time": "2018-02-01T13:46:46+00:00" }, { "name": "sebastian/diff", - "version": "3.0.2", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "720fcc7e9b5cf384ea68d9d930d480907a0c1a29" + "reference": "347c1d8b49c5c3ee30c7040ea6fc446790e6bddd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/720fcc7e9b5cf384ea68d9d930d480907a0c1a29", - "reference": "720fcc7e9b5cf384ea68d9d930d480907a0c1a29", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/347c1d8b49c5c3ee30c7040ea6fc446790e6bddd", + "reference": "347c1d8b49c5c3ee30c7040ea6fc446790e6bddd", "shasum": "" }, "require": { - "php": "^7.1" + "php": "^7.0" }, "require-dev": { - "phpunit/phpunit": "^7.5 || ^8.0", - "symfony/process": "^2 || ^3.3 || ^4" + "phpunit/phpunit": "^6.2" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-master": "2.0-dev" } }, "autoload": { @@ -1075,40 +1126,34 @@ "description": "Diff implementation", "homepage": "https://github.com/sebastianbergmann/diff", "keywords": [ - "diff", - "udiff", - "unidiff", - "unified diff" + "diff" ], - "time": "2019-02-04T06:01:07+00:00" + "time": "2017-08-03T08:09:46+00:00" }, { "name": "sebastian/environment", - "version": "4.1.0", + "version": "3.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "6fda8ce1974b62b14935adc02a9ed38252eca656" + "reference": "cd0871b3975fb7fc44d11314fd1ee20925fce4f5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6fda8ce1974b62b14935adc02a9ed38252eca656", - "reference": "6fda8ce1974b62b14935adc02a9ed38252eca656", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/cd0871b3975fb7fc44d11314fd1ee20925fce4f5", + "reference": "cd0871b3975fb7fc44d11314fd1ee20925fce4f5", "shasum": "" }, "require": { - "php": "^7.1" + "php": "^7.0" }, "require-dev": { - "phpunit/phpunit": "^7.5" - }, - "suggest": { - "ext-posix": "*" + "phpunit/phpunit": "^6.1" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.1-dev" + "dev-master": "3.1.x-dev" } }, "autoload": { @@ -1133,7 +1178,7 @@ "environment", "hhvm" ], - "time": "2019-02-01T05:27:49+00:00" + "time": "2017-07-01T08:51:00+00:00" }, { "name": "sebastian/exporter", @@ -1400,25 +1445,25 @@ }, { "name": "sebastian/resource-operations", - "version": "2.0.1", + "version": "1.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/resource-operations.git", - "reference": "4d7a795d35b889bf80a0cc04e08d77cedfa917a9" + "reference": "ce990bb21759f94aeafd30209e8cfcdfa8bc3f52" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/4d7a795d35b889bf80a0cc04e08d77cedfa917a9", - "reference": "4d7a795d35b889bf80a0cc04e08d77cedfa917a9", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/ce990bb21759f94aeafd30209e8cfcdfa8bc3f52", + "reference": "ce990bb21759f94aeafd30209e8cfcdfa8bc3f52", "shasum": "" }, "require": { - "php": "^7.1" + "php": ">=5.6.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-master": "1.0.x-dev" } }, "autoload": { @@ -1438,7 +1483,7 @@ ], "description": "Provides a list of PHP built-in functions that operate on resources", "homepage": "https://www.github.com/sebastianbergmann/resource-operations", - "time": "2018-10-04T04:07:39+00:00" + "time": "2015-07-28T20:34:47+00:00" }, { "name": "sebastian/version", @@ -1543,16 +1588,16 @@ }, { "name": "theseer/tokenizer", - "version": "1.1.0", + "version": "1.1.3", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "cb2f008f3f05af2893a87208fe6a6c4985483f8b" + "reference": "11336f6f84e16a720dae9d8e6ed5019efa85a0f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/cb2f008f3f05af2893a87208fe6a6c4985483f8b", - "reference": "cb2f008f3f05af2893a87208fe6a6c4985483f8b", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/11336f6f84e16a720dae9d8e6ed5019efa85a0f9", + "reference": "11336f6f84e16a720dae9d8e6ed5019efa85a0f9", "shasum": "" }, "require": { @@ -1579,7 +1624,7 @@ } ], "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", - "time": "2017-04-07T12:08:54+00:00" + "time": "2019-06-13T22:48:21+00:00" }, { "name": "webmozart/assert", diff --git a/vendor-bin/robo/composer.lock b/vendor-bin/robo/composer.lock index 8458df8..2c7301c 100644 --- a/vendor-bin/robo/composer.lock +++ b/vendor-bin/robo/composer.lock @@ -275,16 +275,16 @@ }, { "name": "consolidation/output-formatters", - "version": "3.4.1", + "version": "3.5.0", "source": { "type": "git", "url": "https://github.com/consolidation/output-formatters.git", - "reference": "0881112642ad9059071f13f397f571035b527cb9" + "reference": "99ec998ffb697e0eada5aacf81feebfb13023605" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/consolidation/output-formatters/zipball/0881112642ad9059071f13f397f571035b527cb9", - "reference": "0881112642ad9059071f13f397f571035b527cb9", + "url": "https://api.github.com/repos/consolidation/output-formatters/zipball/99ec998ffb697e0eada5aacf81feebfb13023605", + "reference": "99ec998ffb697e0eada5aacf81feebfb13023605", "shasum": "" }, "require": { @@ -372,7 +372,7 @@ } ], "description": "Format text by applying transformations provided by plug-in formatters.", - "time": "2019-03-14T03:45:44+00:00" + "time": "2019-05-30T23:16:01+00:00" }, { "name": "consolidation/robo", @@ -784,16 +784,16 @@ }, { "name": "pear/archive_tar", - "version": "1.4.6", + "version": "1.4.7", "source": { "type": "git", "url": "https://github.com/pear/Archive_Tar.git", - "reference": "b8e33f9063a7cd1d20f079014f8382b3a7aee47e" + "reference": "7e48add6f8edc3027dd98ad15964b1a28fd0c845" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pear/Archive_Tar/zipball/b8e33f9063a7cd1d20f079014f8382b3a7aee47e", - "reference": "b8e33f9063a7cd1d20f079014f8382b3a7aee47e", + "url": "https://api.github.com/repos/pear/Archive_Tar/zipball/7e48add6f8edc3027dd98ad15964b1a28fd0c845", + "reference": "7e48add6f8edc3027dd98ad15964b1a28fd0c845", "shasum": "" }, "require": { @@ -846,7 +846,7 @@ "archive", "tar" ], - "time": "2019-02-01T11:10:38+00:00" + "time": "2019-04-08T13:15:55+00:00" }, { "name": "pear/console_getopt", @@ -1092,21 +1092,21 @@ }, { "name": "symfony/console", - "version": "v4.2.4", + "version": "v3.4.28", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "9dc2299a016497f9ee620be94524e6c0af0280a9" + "reference": "8e1d1e406dd31727fa70cd5a99cda202e9d6a5c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/9dc2299a016497f9ee620be94524e6c0af0280a9", - "reference": "9dc2299a016497f9ee620be94524e6c0af0280a9", + "url": "https://api.github.com/repos/symfony/console/zipball/8e1d1e406dd31727fa70cd5a99cda202e9d6a5c6", + "reference": "8e1d1e406dd31727fa70cd5a99cda202e9d6a5c6", "shasum": "" }, "require": { - "php": "^7.1.3", - "symfony/contracts": "^1.0", + "php": "^5.5.9|>=7.0.8", + "symfony/debug": "~2.8|~3.0|~4.0", "symfony/polyfill-mbstring": "~1.0" }, "conflict": { @@ -1118,11 +1118,11 @@ }, "require-dev": { "psr/log": "~1.0", - "symfony/config": "~3.4|~4.0", + "symfony/config": "~3.3|~4.0", "symfony/dependency-injection": "~3.4|~4.0", - "symfony/event-dispatcher": "~3.4|~4.0", + "symfony/event-dispatcher": "~2.8|~3.0|~4.0", "symfony/lock": "~3.4|~4.0", - "symfony/process": "~3.4|~4.0" + "symfony/process": "~3.3|~4.0" }, "suggest": { "psr/log": "For using the console logger", @@ -1133,7 +1133,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "3.4-dev" } }, "autoload": { @@ -1160,48 +1160,44 @@ ], "description": "Symfony Console Component", "homepage": "https://symfony.com", - "time": "2019-02-23T15:17:42+00:00" + "time": "2019-05-09T08:42:51+00:00" }, { - "name": "symfony/contracts", - "version": "v1.0.2", + "name": "symfony/debug", + "version": "v3.4.28", "source": { "type": "git", - "url": "https://github.com/symfony/contracts.git", - "reference": "1aa7ab2429c3d594dd70689604b5cf7421254cdf" + "url": "https://github.com/symfony/debug.git", + "reference": "671fc55bd14800668b1d0a3708c3714940e30a8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/contracts/zipball/1aa7ab2429c3d594dd70689604b5cf7421254cdf", - "reference": "1aa7ab2429c3d594dd70689604b5cf7421254cdf", + "url": "https://api.github.com/repos/symfony/debug/zipball/671fc55bd14800668b1d0a3708c3714940e30a8c", + "reference": "671fc55bd14800668b1d0a3708c3714940e30a8c", "shasum": "" }, "require": { - "php": "^7.1.3" + "php": "^5.5.9|>=7.0.8", + "psr/log": "~1.0" }, - "require-dev": { - "psr/cache": "^1.0", - "psr/container": "^1.0" + "conflict": { + "symfony/http-kernel": ">=2.3,<2.3.24|~2.4.0|>=2.5,<2.5.9|>=2.6,<2.6.2" }, - "suggest": { - "psr/cache": "When using the Cache contracts", - "psr/container": "When using the Service contracts", - "symfony/cache-contracts-implementation": "", - "symfony/service-contracts-implementation": "", - "symfony/translation-contracts-implementation": "" + "require-dev": { + "symfony/http-kernel": "~2.8|~3.0|~4.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-master": "3.4-dev" } }, "autoload": { "psr-4": { - "Symfony\\Contracts\\": "" + "Symfony\\Component\\Debug\\": "" }, "exclude-from-classmap": [ - "**/Tests/" + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -1210,53 +1206,44 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "A set of abstractions extracted out of the Symfony components", + "description": "Symfony Debug Component", "homepage": "https://symfony.com", - "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" - ], - "time": "2018-12-05T08:06:11+00:00" + "time": "2019-05-18T13:32:47+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v4.2.4", + "version": "v3.4.28", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "3354d2e6af986dd71f68b4e5cf4a933ab58697fb" + "reference": "a088aafcefb4eef2520a290ed82e4374092a6dff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/3354d2e6af986dd71f68b4e5cf4a933ab58697fb", - "reference": "3354d2e6af986dd71f68b4e5cf4a933ab58697fb", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/a088aafcefb4eef2520a290ed82e4374092a6dff", + "reference": "a088aafcefb4eef2520a290ed82e4374092a6dff", "shasum": "" }, "require": { - "php": "^7.1.3", - "symfony/contracts": "^1.0" + "php": "^5.5.9|>=7.0.8" }, "conflict": { - "symfony/dependency-injection": "<3.4" + "symfony/dependency-injection": "<3.3" }, "require-dev": { "psr/log": "~1.0", - "symfony/config": "~3.4|~4.0", - "symfony/dependency-injection": "~3.4|~4.0", - "symfony/expression-language": "~3.4|~4.0", - "symfony/stopwatch": "~3.4|~4.0" + "symfony/config": "~2.8|~3.0|~4.0", + "symfony/dependency-injection": "~3.3|~4.0", + "symfony/expression-language": "~2.8|~3.0|~4.0", + "symfony/stopwatch": "~2.8|~3.0|~4.0" }, "suggest": { "symfony/dependency-injection": "", @@ -1265,7 +1252,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "3.4-dev" } }, "autoload": { @@ -1292,30 +1279,30 @@ ], "description": "Symfony EventDispatcher Component", "homepage": "https://symfony.com", - "time": "2019-02-23T15:17:42+00:00" + "time": "2019-04-02T08:51:52+00:00" }, { "name": "symfony/filesystem", - "version": "v4.2.4", + "version": "v3.4.28", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "e16b9e471703b2c60b95f14d31c1239f68f11601" + "reference": "acf99758b1df8e9295e6b85aa69f294565c9fedb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/e16b9e471703b2c60b95f14d31c1239f68f11601", - "reference": "e16b9e471703b2c60b95f14d31c1239f68f11601", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/acf99758b1df8e9295e6b85aa69f294565c9fedb", + "reference": "acf99758b1df8e9295e6b85aa69f294565c9fedb", "shasum": "" }, "require": { - "php": "^7.1.3", + "php": "^5.5.9|>=7.0.8", "symfony/polyfill-ctype": "~1.8" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "3.4-dev" } }, "autoload": { @@ -1342,29 +1329,29 @@ ], "description": "Symfony Filesystem Component", "homepage": "https://symfony.com", - "time": "2019-02-07T11:40:08+00:00" + "time": "2019-02-04T21:34:32+00:00" }, { "name": "symfony/finder", - "version": "v4.2.4", + "version": "v3.4.28", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "267b7002c1b70ea80db0833c3afe05f0fbde580a" + "reference": "fa5d962a71f2169dfe1cbae217fa5a2799859f6c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/267b7002c1b70ea80db0833c3afe05f0fbde580a", - "reference": "267b7002c1b70ea80db0833c3afe05f0fbde580a", + "url": "https://api.github.com/repos/symfony/finder/zipball/fa5d962a71f2169dfe1cbae217fa5a2799859f6c", + "reference": "fa5d962a71f2169dfe1cbae217fa5a2799859f6c", "shasum": "" }, "require": { - "php": "^7.1.3" + "php": "^5.5.9|>=7.0.8" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "3.4-dev" } }, "autoload": { @@ -1391,7 +1378,7 @@ ], "description": "Symfony Finder Component", "homepage": "https://symfony.com", - "time": "2019-02-23T15:42:05+00:00" + "time": "2019-05-24T12:25:55+00:00" }, { "name": "symfony/polyfill-ctype", @@ -1512,16 +1499,16 @@ }, { "name": "symfony/process", - "version": "v3.4.23", + "version": "v3.4.28", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "009f8dda80930e89e8344a4e310b08f9ff07dd2e" + "reference": "afe411c2a6084f25cff55a01d0d4e1474c97ff13" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/009f8dda80930e89e8344a4e310b08f9ff07dd2e", - "reference": "009f8dda80930e89e8344a4e310b08f9ff07dd2e", + "url": "https://api.github.com/repos/symfony/process/zipball/afe411c2a6084f25cff55a01d0d4e1474c97ff13", + "reference": "afe411c2a6084f25cff55a01d0d4e1474c97ff13", "shasum": "" }, "require": { @@ -1557,24 +1544,24 @@ ], "description": "Symfony Process Component", "homepage": "https://symfony.com", - "time": "2019-01-16T13:27:11+00:00" + "time": "2019-05-22T12:54:11+00:00" }, { "name": "symfony/yaml", - "version": "v4.2.4", + "version": "v3.4.28", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "761fa560a937fd7686e5274ff89dcfa87a5047df" + "reference": "212a27b731e5bfb735679d1ffaac82bd6a1dc996" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/761fa560a937fd7686e5274ff89dcfa87a5047df", - "reference": "761fa560a937fd7686e5274ff89dcfa87a5047df", + "url": "https://api.github.com/repos/symfony/yaml/zipball/212a27b731e5bfb735679d1ffaac82bd6a1dc996", + "reference": "212a27b731e5bfb735679d1ffaac82bd6a1dc996", "shasum": "" }, "require": { - "php": "^7.1.3", + "php": "^5.5.9|>=7.0.8", "symfony/polyfill-ctype": "~1.8" }, "conflict": { @@ -1589,7 +1576,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "3.4-dev" } }, "autoload": { @@ -1616,7 +1603,7 @@ ], "description": "Symfony Yaml Component", "homepage": "https://symfony.com", - "time": "2019-02-23T15:17:42+00:00" + "time": "2019-03-25T07:48:46+00:00" } ], "packages-dev": [], From 62fe3a7298483cd7f18ebeafc2bc67dce673ca2b Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 21 Jun 2019 10:30:36 -0400 Subject: [PATCH 120/142] Fix case of vfsstream tool dependency --- vendor-bin/csfixer/composer.lock | 374 +++++++++++++++++++++++-------- vendor-bin/phpunit/composer.json | 2 +- vendor-bin/phpunit/composer.lock | 317 +++++++++++--------------- vendor-bin/robo/composer.lock | 280 ++++++++++++++++------- 4 files changed, 617 insertions(+), 356 deletions(-) diff --git a/vendor-bin/csfixer/composer.lock b/vendor-bin/csfixer/composer.lock index 2b7494e..a5945c4 100644 --- a/vendor-bin/csfixer/composer.lock +++ b/vendor-bin/csfixer/composer.lock @@ -114,30 +114,30 @@ }, { "name": "doctrine/annotations", - "version": "v1.4.0", + "version": "v1.6.1", "source": { "type": "git", "url": "https://github.com/doctrine/annotations.git", - "reference": "54cacc9b81758b14e3ce750f205a393d52339e97" + "reference": "53120e0eb10355388d6ccbe462f1fea34ddadb24" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/annotations/zipball/54cacc9b81758b14e3ce750f205a393d52339e97", - "reference": "54cacc9b81758b14e3ce750f205a393d52339e97", + "url": "https://api.github.com/repos/doctrine/annotations/zipball/53120e0eb10355388d6ccbe462f1fea34ddadb24", + "reference": "53120e0eb10355388d6ccbe462f1fea34ddadb24", "shasum": "" }, "require": { "doctrine/lexer": "1.*", - "php": "^5.6 || ^7.0" + "php": "^7.1" }, "require-dev": { "doctrine/cache": "1.*", - "phpunit/phpunit": "^5.7" + "phpunit/phpunit": "^6.4" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.4.x-dev" + "dev-master": "1.6.x-dev" } }, "autoload": { @@ -178,7 +178,7 @@ "docblock", "parser" ], - "time": "2017-02-24T16:22:25+00:00" + "time": "2019-03-25T19:12:02+00:00" }, { "name": "doctrine/lexer", @@ -424,6 +424,55 @@ ], "time": "2018-02-15T16:58:55+00:00" }, + { + "name": "psr/container", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/b7ce3b176482dbbc1245ebf52b181af44c2cf55f", + "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "time": "2017-02-14T16:28:37+00:00" + }, { "name": "psr/log", "version": "1.1.0", @@ -473,25 +522,27 @@ }, { "name": "symfony/console", - "version": "v3.4.28", + "version": "v4.3.1", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "8e1d1e406dd31727fa70cd5a99cda202e9d6a5c6" + "reference": "d50bbeeb0e17e6dd4124ea391eff235e932cbf64" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/8e1d1e406dd31727fa70cd5a99cda202e9d6a5c6", - "reference": "8e1d1e406dd31727fa70cd5a99cda202e9d6a5c6", + "url": "https://api.github.com/repos/symfony/console/zipball/d50bbeeb0e17e6dd4124ea391eff235e932cbf64", + "reference": "d50bbeeb0e17e6dd4124ea391eff235e932cbf64", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", - "symfony/debug": "~2.8|~3.0|~4.0", - "symfony/polyfill-mbstring": "~1.0" + "php": "^7.1.3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php73": "^1.8", + "symfony/service-contracts": "^1.1" }, "conflict": { "symfony/dependency-injection": "<3.4", + "symfony/event-dispatcher": "<4.3", "symfony/process": "<3.3" }, "provide": { @@ -499,11 +550,12 @@ }, "require-dev": { "psr/log": "~1.0", - "symfony/config": "~3.3|~4.0", + "symfony/config": "~3.4|~4.0", "symfony/dependency-injection": "~3.4|~4.0", - "symfony/event-dispatcher": "~2.8|~3.0|~4.0", + "symfony/event-dispatcher": "^4.3", "symfony/lock": "~3.4|~4.0", - "symfony/process": "~3.3|~4.0" + "symfony/process": "~3.4|~4.0", + "symfony/var-dumper": "^4.3" }, "suggest": { "psr/log": "For using the console logger", @@ -514,7 +566,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -541,41 +593,55 @@ ], "description": "Symfony Console Component", "homepage": "https://symfony.com", - "time": "2019-05-09T08:42:51+00:00" + "time": "2019-06-05T13:25:51+00:00" }, { - "name": "symfony/debug", - "version": "v3.4.28", + "name": "symfony/event-dispatcher", + "version": "v4.3.1", "source": { "type": "git", - "url": "https://github.com/symfony/debug.git", - "reference": "671fc55bd14800668b1d0a3708c3714940e30a8c" + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "4e6c670af81c4fb0b6c08b035530a9915d0b691f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/debug/zipball/671fc55bd14800668b1d0a3708c3714940e30a8c", - "reference": "671fc55bd14800668b1d0a3708c3714940e30a8c", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/4e6c670af81c4fb0b6c08b035530a9915d0b691f", + "reference": "4e6c670af81c4fb0b6c08b035530a9915d0b691f", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", - "psr/log": "~1.0" + "php": "^7.1.3", + "symfony/event-dispatcher-contracts": "^1.1" }, "conflict": { - "symfony/http-kernel": ">=2.3,<2.3.24|~2.4.0|>=2.5,<2.5.9|>=2.6,<2.6.2" + "symfony/dependency-injection": "<3.4" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "1.1" }, "require-dev": { - "symfony/http-kernel": "~2.8|~3.0|~4.0" + "psr/log": "~1.0", + "symfony/config": "~3.4|~4.0", + "symfony/dependency-injection": "~3.4|~4.0", + "symfony/expression-language": "~3.4|~4.0", + "symfony/http-foundation": "^3.4|^4.0", + "symfony/service-contracts": "^1.1", + "symfony/stopwatch": "~3.4|~4.0" + }, + "suggest": { + "symfony/dependency-injection": "", + "symfony/http-kernel": "" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.3-dev" } }, "autoload": { "psr-4": { - "Symfony\\Component\\Debug\\": "" + "Symfony\\Component\\EventDispatcher\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -595,54 +661,41 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony Debug Component", + "description": "Symfony EventDispatcher Component", "homepage": "https://symfony.com", - "time": "2019-05-18T13:32:47+00:00" + "time": "2019-05-30T16:10:05+00:00" }, { - "name": "symfony/event-dispatcher", - "version": "v3.4.28", + "name": "symfony/event-dispatcher-contracts", + "version": "v1.1.5", "source": { "type": "git", - "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "a088aafcefb4eef2520a290ed82e4374092a6dff" + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c61766f4440ca687de1084a5c00b08e167a2575c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/a088aafcefb4eef2520a290ed82e4374092a6dff", - "reference": "a088aafcefb4eef2520a290ed82e4374092a6dff", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c61766f4440ca687de1084a5c00b08e167a2575c", + "reference": "c61766f4440ca687de1084a5c00b08e167a2575c", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" - }, - "conflict": { - "symfony/dependency-injection": "<3.3" - }, - "require-dev": { - "psr/log": "~1.0", - "symfony/config": "~2.8|~3.0|~4.0", - "symfony/dependency-injection": "~3.3|~4.0", - "symfony/expression-language": "~2.8|~3.0|~4.0", - "symfony/stopwatch": "~2.8|~3.0|~4.0" + "php": "^7.1.3" }, "suggest": { - "symfony/dependency-injection": "", - "symfony/http-kernel": "" + "psr/event-dispatcher": "", + "symfony/event-dispatcher-implementation": "" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "1.1-dev" } }, "autoload": { "psr-4": { - "Symfony\\Component\\EventDispatcher\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Symfony\\Contracts\\EventDispatcher\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -650,40 +703,48 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony EventDispatcher Component", + "description": "Generic abstractions related to dispatching event", "homepage": "https://symfony.com", - "time": "2019-04-02T08:51:52+00:00" + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "time": "2019-06-20T06:46:26+00:00" }, { "name": "symfony/filesystem", - "version": "v3.4.28", + "version": "v4.3.1", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "acf99758b1df8e9295e6b85aa69f294565c9fedb" + "reference": "bf2af40d738dec5e433faea7b00daa4431d0a4cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/acf99758b1df8e9295e6b85aa69f294565c9fedb", - "reference": "acf99758b1df8e9295e6b85aa69f294565c9fedb", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/bf2af40d738dec5e433faea7b00daa4431d0a4cf", + "reference": "bf2af40d738dec5e433faea7b00daa4431d0a4cf", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", + "php": "^7.1.3", "symfony/polyfill-ctype": "~1.8" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -710,29 +771,29 @@ ], "description": "Symfony Filesystem Component", "homepage": "https://symfony.com", - "time": "2019-02-04T21:34:32+00:00" + "time": "2019-06-03T20:27:40+00:00" }, { "name": "symfony/finder", - "version": "v3.4.28", + "version": "v4.3.1", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "fa5d962a71f2169dfe1cbae217fa5a2799859f6c" + "reference": "b3d4f4c0e4eadfdd8b296af9ca637cfbf51d8176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/fa5d962a71f2169dfe1cbae217fa5a2799859f6c", - "reference": "fa5d962a71f2169dfe1cbae217fa5a2799859f6c", + "url": "https://api.github.com/repos/symfony/finder/zipball/b3d4f4c0e4eadfdd8b296af9ca637cfbf51d8176", + "reference": "b3d4f4c0e4eadfdd8b296af9ca637cfbf51d8176", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "php": "^7.1.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -759,29 +820,29 @@ ], "description": "Symfony Finder Component", "homepage": "https://symfony.com", - "time": "2019-05-24T12:25:55+00:00" + "time": "2019-05-26T20:47:49+00:00" }, { "name": "symfony/options-resolver", - "version": "v3.4.28", + "version": "v4.3.1", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "ed3b397f9c07c8ca388b2a1ef744403b4d4ecc44" + "reference": "914e0edcb7cd0c9f494bc023b1d47534f4542332" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/ed3b397f9c07c8ca388b2a1ef744403b4d4ecc44", - "reference": "ed3b397f9c07c8ca388b2a1ef744403b4d4ecc44", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/914e0edcb7cd0c9f494bc023b1d47534f4542332", + "reference": "914e0edcb7cd0c9f494bc023b1d47534f4542332", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "php": "^7.1.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -813,7 +874,7 @@ "configuration", "options" ], - "time": "2019-04-10T16:00:48+00:00" + "time": "2019-05-10T05:38:46+00:00" }, { "name": "symfony/polyfill-ctype", @@ -1046,27 +1107,85 @@ ], "time": "2019-02-06T07:57:58+00:00" }, + { + "name": "symfony/polyfill-php73", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php73.git", + "reference": "d1fb4abcc0c47be136208ad9d68bf59f1ee17abd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/d1fb4abcc0c47be136208ad9d68bf59f1ee17abd", + "reference": "d1fb4abcc0c47be136208ad9d68bf59f1ee17abd", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.11-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Php73\\": "" + }, + "files": [ + "bootstrap.php" + ], + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "time": "2019-02-06T07:57:58+00:00" + }, { "name": "symfony/process", - "version": "v3.4.28", + "version": "v4.3.1", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "afe411c2a6084f25cff55a01d0d4e1474c97ff13" + "reference": "856d35814cf287480465bb7a6c413bb7f5f5e69c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/afe411c2a6084f25cff55a01d0d4e1474c97ff13", - "reference": "afe411c2a6084f25cff55a01d0d4e1474c97ff13", + "url": "https://api.github.com/repos/symfony/process/zipball/856d35814cf287480465bb7a6c413bb7f5f5e69c", + "reference": "856d35814cf287480465bb7a6c413bb7f5f5e69c", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "php": "^7.1.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -1093,29 +1212,88 @@ ], "description": "Symfony Process Component", "homepage": "https://symfony.com", - "time": "2019-05-22T12:54:11+00:00" + "time": "2019-05-30T16:10:05+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v1.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "f391a00de78ec7ec8cf5cdcdae59ec7b883edb8d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f391a00de78ec7ec8cf5cdcdae59ec7b883edb8d", + "reference": "f391a00de78ec7ec8cf5cdcdae59ec7b883edb8d", + "shasum": "" + }, + "require": { + "php": "^7.1.3", + "psr/container": "^1.0" + }, + "suggest": { + "symfony/service-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "time": "2019-06-13T11:15:36+00:00" }, { "name": "symfony/stopwatch", - "version": "v3.4.28", + "version": "v4.3.1", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "2a651c2645c10bbedd21170771f122d935e0dd58" + "reference": "6b100e9309e8979cf1978ac1778eb155c1f7d93b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/2a651c2645c10bbedd21170771f122d935e0dd58", - "reference": "2a651c2645c10bbedd21170771f122d935e0dd58", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/6b100e9309e8979cf1978ac1778eb155c1f7d93b", + "reference": "6b100e9309e8979cf1978ac1778eb155c1f7d93b", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "php": "^7.1.3", + "symfony/service-contracts": "^1.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -1142,7 +1320,7 @@ ], "description": "Symfony Stopwatch Component", "homepage": "https://symfony.com", - "time": "2019-01-16T09:39:14+00:00" + "time": "2019-05-27T08:16:38+00:00" } ], "packages-dev": [], diff --git a/vendor-bin/phpunit/composer.json b/vendor-bin/phpunit/composer.json index 3afc6e7..7faefcb 100644 --- a/vendor-bin/phpunit/composer.json +++ b/vendor-bin/phpunit/composer.json @@ -3,7 +3,7 @@ "phpunit/phpunit": "6.* | 7.*", "phake/phake": "^3.0", "clue/arguments": "^2.0", - "mikey179/vfsStream": "^1.6", + "mikey179/vfsstream": "^1.6", "webmozart/glob": "^4.1" } } diff --git a/vendor-bin/phpunit/composer.lock b/vendor-bin/phpunit/composer.lock index 3f6a3a4..bc43590 100644 --- a/vendor-bin/phpunit/composer.lock +++ b/vendor-bin/phpunit/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "57ab1526d0611e8766f67b8e0fa16912", + "content-hash": "0efc271cb10b6582cac5f373a48fc969", "packages": [ { "name": "clue/arguments", @@ -58,32 +58,34 @@ }, { "name": "doctrine/instantiator", - "version": "1.0.5", + "version": "1.2.0", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "8e884e78f9f0eb1329e445619e04456e64d8051d" + "reference": "a2c590166b2133a4633738648b6b064edae0814a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/8e884e78f9f0eb1329e445619e04456e64d8051d", - "reference": "8e884e78f9f0eb1329e445619e04456e64d8051d", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/a2c590166b2133a4633738648b6b064edae0814a", + "reference": "a2c590166b2133a4633738648b6b064edae0814a", "shasum": "" }, "require": { - "php": ">=5.3,<8.0-DEV" + "php": "^7.1" }, "require-dev": { - "athletic/athletic": "~0.1.8", + "doctrine/coding-standard": "^6.0", "ext-pdo": "*", "ext-phar": "*", - "phpunit/phpunit": "~4.0", - "squizlabs/php_codesniffer": "~2.0" + "phpbench/phpbench": "^0.13", + "phpstan/phpstan-phpunit": "^0.11", + "phpstan/phpstan-shim": "^0.11", + "phpunit/phpunit": "^7.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "1.2.x-dev" } }, "autoload": { @@ -103,12 +105,12 @@ } ], "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", - "homepage": "https://github.com/doctrine/instantiator", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", "keywords": [ "constructor", "instantiate" ], - "time": "2015-06-14T21:17:01+00:00" + "time": "2019-03-17T17:37:11+00:00" }, { "name": "mikey179/vfsstream", @@ -158,25 +160,28 @@ }, { "name": "myclabs/deep-copy", - "version": "1.7.0", + "version": "1.9.1", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "3b8a3a99ba1f6a3952ac2747d989303cbd6b7a3e" + "reference": "e6828efaba2c9b79f4499dae1d66ef8bfa7b2b72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/3b8a3a99ba1f6a3952ac2747d989303cbd6b7a3e", - "reference": "3b8a3a99ba1f6a3952ac2747d989303cbd6b7a3e", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/e6828efaba2c9b79f4499dae1d66ef8bfa7b2b72", + "reference": "e6828efaba2c9b79f4499dae1d66ef8bfa7b2b72", "shasum": "" }, "require": { - "php": "^5.6 || ^7.0" + "php": "^7.1" + }, + "replace": { + "myclabs/deep-copy": "self.version" }, "require-dev": { "doctrine/collections": "^1.0", "doctrine/common": "^2.6", - "phpunit/phpunit": "^4.1" + "phpunit/phpunit": "^7.1" }, "type": "library", "autoload": { @@ -199,7 +204,7 @@ "object", "object graph" ], - "time": "2017-10-19T19:58:43+00:00" + "time": "2019-04-07T13:18:21+00:00" }, { "name": "phake/phake", @@ -261,22 +266,22 @@ }, { "name": "phar-io/manifest", - "version": "1.0.1", + "version": "1.0.3", "source": { "type": "git", "url": "https://github.com/phar-io/manifest.git", - "reference": "2df402786ab5368a0169091f61a7c1e0eb6852d0" + "reference": "7761fcacf03b4d4f16e7ccb606d4879ca431fcf4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/manifest/zipball/2df402786ab5368a0169091f61a7c1e0eb6852d0", - "reference": "2df402786ab5368a0169091f61a7c1e0eb6852d0", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/7761fcacf03b4d4f16e7ccb606d4879ca431fcf4", + "reference": "7761fcacf03b4d4f16e7ccb606d4879ca431fcf4", "shasum": "" }, "require": { "ext-dom": "*", "ext-phar": "*", - "phar-io/version": "^1.0.1", + "phar-io/version": "^2.0", "php": "^5.6 || ^7.0" }, "type": "library", @@ -312,20 +317,20 @@ } ], "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", - "time": "2017-03-05T18:14:27+00:00" + "time": "2018-07-08T19:23:20+00:00" }, { "name": "phar-io/version", - "version": "1.0.1", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/phar-io/version.git", - "reference": "a70c0ced4be299a63d32fa96d9281d03e94041df" + "reference": "45a2ec53a73c70ce41d55cedef9063630abaf1b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/version/zipball/a70c0ced4be299a63d32fa96d9281d03e94041df", - "reference": "a70c0ced4be299a63d32fa96d9281d03e94041df", + "url": "https://api.github.com/repos/phar-io/version/zipball/45a2ec53a73c70ce41d55cedef9063630abaf1b6", + "reference": "45a2ec53a73c70ce41d55cedef9063630abaf1b6", "shasum": "" }, "require": { @@ -359,7 +364,7 @@ } ], "description": "Library for handling version information and constraints", - "time": "2017-03-05T17:38:23+00:00" + "time": "2018-07-08T19:19:57+00:00" }, { "name": "phpdocumentor/reflection-common", @@ -578,40 +583,40 @@ }, { "name": "phpunit/php-code-coverage", - "version": "5.3.2", + "version": "6.1.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "c89677919c5dd6d3b3852f230a663118762218ac" + "reference": "807e6013b00af69b6c5d9ceb4282d0393dbb9d8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/c89677919c5dd6d3b3852f230a663118762218ac", - "reference": "c89677919c5dd6d3b3852f230a663118762218ac", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/807e6013b00af69b6c5d9ceb4282d0393dbb9d8d", + "reference": "807e6013b00af69b6c5d9ceb4282d0393dbb9d8d", "shasum": "" }, "require": { "ext-dom": "*", "ext-xmlwriter": "*", - "php": "^7.0", - "phpunit/php-file-iterator": "^1.4.2", + "php": "^7.1", + "phpunit/php-file-iterator": "^2.0", "phpunit/php-text-template": "^1.2.1", - "phpunit/php-token-stream": "^2.0.1", + "phpunit/php-token-stream": "^3.0", "sebastian/code-unit-reverse-lookup": "^1.0.1", - "sebastian/environment": "^3.0", + "sebastian/environment": "^3.1 || ^4.0", "sebastian/version": "^2.0.1", "theseer/tokenizer": "^1.1" }, "require-dev": { - "phpunit/phpunit": "^6.0" + "phpunit/phpunit": "^7.0" }, "suggest": { - "ext-xdebug": "^2.5.5" + "ext-xdebug": "^2.6.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "5.3.x-dev" + "dev-master": "6.1-dev" } }, "autoload": { @@ -637,29 +642,32 @@ "testing", "xunit" ], - "time": "2018-04-06T15:36:58+00:00" + "time": "2018-10-31T16:06:48+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "1.4.5", + "version": "2.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "730b01bc3e867237eaac355e06a36b85dd93a8b4" + "reference": "050bedf145a257b1ff02746c31894800e5122946" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/730b01bc3e867237eaac355e06a36b85dd93a8b4", - "reference": "730b01bc3e867237eaac355e06a36b85dd93a8b4", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/050bedf145a257b1ff02746c31894800e5122946", + "reference": "050bedf145a257b1ff02746c31894800e5122946", "shasum": "" }, "require": { - "php": ">=5.3.3" + "php": "^7.1" + }, + "require-dev": { + "phpunit/phpunit": "^7.1" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.4.x-dev" + "dev-master": "2.0.x-dev" } }, "autoload": { @@ -674,7 +682,7 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sb@sebastian-bergmann.de", + "email": "sebastian@phpunit.de", "role": "lead" } ], @@ -684,7 +692,7 @@ "filesystem", "iterator" ], - "time": "2017-11-27T13:52:08+00:00" + "time": "2018-09-13T20:33:42+00:00" }, { "name": "phpunit/php-text-template", @@ -729,28 +737,28 @@ }, { "name": "phpunit/php-timer", - "version": "1.0.9", + "version": "2.1.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "3dcf38ca72b158baf0bc245e9184d3fdffa9c46f" + "reference": "1038454804406b0b5f5f520358e78c1c2f71501e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3dcf38ca72b158baf0bc245e9184d3fdffa9c46f", - "reference": "3dcf38ca72b158baf0bc245e9184d3fdffa9c46f", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/1038454804406b0b5f5f520358e78c1c2f71501e", + "reference": "1038454804406b0b5f5f520358e78c1c2f71501e", "shasum": "" }, "require": { - "php": "^5.3.3 || ^7.0" + "php": "^7.1" }, "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0" + "phpunit/phpunit": "^7.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-master": "2.1-dev" } }, "autoload": { @@ -765,7 +773,7 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sb@sebastian-bergmann.de", + "email": "sebastian@phpunit.de", "role": "lead" } ], @@ -774,33 +782,33 @@ "keywords": [ "timer" ], - "time": "2017-02-26T11:10:40+00:00" + "time": "2019-06-07T04:22:29+00:00" }, { "name": "phpunit/php-token-stream", - "version": "2.0.2", + "version": "3.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-token-stream.git", - "reference": "791198a2c6254db10131eecfe8c06670700904db" + "reference": "c99e3be9d3e85f60646f152f9002d46ed7770d18" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/791198a2c6254db10131eecfe8c06670700904db", - "reference": "791198a2c6254db10131eecfe8c06670700904db", + "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/c99e3be9d3e85f60646f152f9002d46ed7770d18", + "reference": "c99e3be9d3e85f60646f152f9002d46ed7770d18", "shasum": "" }, "require": { "ext-tokenizer": "*", - "php": "^7.0" + "php": "^7.1" }, "require-dev": { - "phpunit/phpunit": "^6.2.4" + "phpunit/phpunit": "^7.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-master": "3.0-dev" } }, "autoload": { @@ -823,57 +831,57 @@ "keywords": [ "tokenizer" ], - "time": "2017-11-27T05:48:46+00:00" + "time": "2018-10-30T05:52:18+00:00" }, { "name": "phpunit/phpunit", - "version": "6.5.14", + "version": "7.5.13", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "bac23fe7ff13dbdb461481f706f0e9fe746334b7" + "reference": "b9278591caa8630127f96c63b598712b699e671c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/bac23fe7ff13dbdb461481f706f0e9fe746334b7", - "reference": "bac23fe7ff13dbdb461481f706f0e9fe746334b7", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b9278591caa8630127f96c63b598712b699e671c", + "reference": "b9278591caa8630127f96c63b598712b699e671c", "shasum": "" }, "require": { + "doctrine/instantiator": "^1.1", "ext-dom": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", "ext-xml": "*", - "myclabs/deep-copy": "^1.6.1", - "phar-io/manifest": "^1.0.1", - "phar-io/version": "^1.0", - "php": "^7.0", + "myclabs/deep-copy": "^1.7", + "phar-io/manifest": "^1.0.2", + "phar-io/version": "^2.0", + "php": "^7.1", "phpspec/prophecy": "^1.7", - "phpunit/php-code-coverage": "^5.3", - "phpunit/php-file-iterator": "^1.4.3", + "phpunit/php-code-coverage": "^6.0.7", + "phpunit/php-file-iterator": "^2.0.1", "phpunit/php-text-template": "^1.2.1", - "phpunit/php-timer": "^1.0.9", - "phpunit/phpunit-mock-objects": "^5.0.9", - "sebastian/comparator": "^2.1", - "sebastian/diff": "^2.0", - "sebastian/environment": "^3.1", + "phpunit/php-timer": "^2.1", + "sebastian/comparator": "^3.0", + "sebastian/diff": "^3.0", + "sebastian/environment": "^4.0", "sebastian/exporter": "^3.1", "sebastian/global-state": "^2.0", "sebastian/object-enumerator": "^3.0.3", - "sebastian/resource-operations": "^1.0", + "sebastian/resource-operations": "^2.0", "sebastian/version": "^2.0.1" }, "conflict": { - "phpdocumentor/reflection-docblock": "3.0.2", - "phpunit/dbunit": "<3.0" + "phpunit/phpunit-mock-objects": "*" }, "require-dev": { "ext-pdo": "*" }, "suggest": { + "ext-soap": "*", "ext-xdebug": "*", - "phpunit/php-invoker": "^1.1" + "phpunit/php-invoker": "^2.0" }, "bin": [ "phpunit" @@ -881,7 +889,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "6.5.x-dev" + "dev-master": "7.5-dev" } }, "autoload": { @@ -907,67 +915,7 @@ "testing", "xunit" ], - "time": "2019-02-01T05:22:47+00:00" - }, - { - "name": "phpunit/phpunit-mock-objects", - "version": "5.0.10", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/phpunit-mock-objects.git", - "reference": "cd1cf05c553ecfec36b170070573e540b67d3f1f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/cd1cf05c553ecfec36b170070573e540b67d3f1f", - "reference": "cd1cf05c553ecfec36b170070573e540b67d3f1f", - "shasum": "" - }, - "require": { - "doctrine/instantiator": "^1.0.5", - "php": "^7.0", - "phpunit/php-text-template": "^1.2.1", - "sebastian/exporter": "^3.1" - }, - "conflict": { - "phpunit/phpunit": "<6.0" - }, - "require-dev": { - "phpunit/phpunit": "^6.5.11" - }, - "suggest": { - "ext-soap": "*" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.0.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Mock Object library for PHPUnit", - "homepage": "https://github.com/sebastianbergmann/phpunit-mock-objects/", - "keywords": [ - "mock", - "xunit" - ], - "abandoned": true, - "time": "2018-08-09T05:50:03+00:00" + "time": "2019-06-19T12:01:51+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", @@ -1016,30 +964,30 @@ }, { "name": "sebastian/comparator", - "version": "2.1.3", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "34369daee48eafb2651bea869b4b15d75ccc35f9" + "reference": "5de4fc177adf9bce8df98d8d141a7559d7ccf6da" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/34369daee48eafb2651bea869b4b15d75ccc35f9", - "reference": "34369daee48eafb2651bea869b4b15d75ccc35f9", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/5de4fc177adf9bce8df98d8d141a7559d7ccf6da", + "reference": "5de4fc177adf9bce8df98d8d141a7559d7ccf6da", "shasum": "" }, "require": { - "php": "^7.0", - "sebastian/diff": "^2.0 || ^3.0", + "php": "^7.1", + "sebastian/diff": "^3.0", "sebastian/exporter": "^3.1" }, "require-dev": { - "phpunit/phpunit": "^6.4" + "phpunit/phpunit": "^7.1" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.1.x-dev" + "dev-master": "3.0-dev" } }, "autoload": { @@ -1076,32 +1024,33 @@ "compare", "equality" ], - "time": "2018-02-01T13:46:46+00:00" + "time": "2018-07-12T15:12:46+00:00" }, { "name": "sebastian/diff", - "version": "2.0.1", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "347c1d8b49c5c3ee30c7040ea6fc446790e6bddd" + "reference": "720fcc7e9b5cf384ea68d9d930d480907a0c1a29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/347c1d8b49c5c3ee30c7040ea6fc446790e6bddd", - "reference": "347c1d8b49c5c3ee30c7040ea6fc446790e6bddd", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/720fcc7e9b5cf384ea68d9d930d480907a0c1a29", + "reference": "720fcc7e9b5cf384ea68d9d930d480907a0c1a29", "shasum": "" }, "require": { - "php": "^7.0" + "php": "^7.1" }, "require-dev": { - "phpunit/phpunit": "^6.2" + "phpunit/phpunit": "^7.5 || ^8.0", + "symfony/process": "^2 || ^3.3 || ^4" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-master": "3.0-dev" } }, "autoload": { @@ -1126,34 +1075,40 @@ "description": "Diff implementation", "homepage": "https://github.com/sebastianbergmann/diff", "keywords": [ - "diff" + "diff", + "udiff", + "unidiff", + "unified diff" ], - "time": "2017-08-03T08:09:46+00:00" + "time": "2019-02-04T06:01:07+00:00" }, { "name": "sebastian/environment", - "version": "3.1.0", + "version": "4.2.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "cd0871b3975fb7fc44d11314fd1ee20925fce4f5" + "reference": "f2a2c8e1c97c11ace607a7a667d73d47c19fe404" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/cd0871b3975fb7fc44d11314fd1ee20925fce4f5", - "reference": "cd0871b3975fb7fc44d11314fd1ee20925fce4f5", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/f2a2c8e1c97c11ace607a7a667d73d47c19fe404", + "reference": "f2a2c8e1c97c11ace607a7a667d73d47c19fe404", "shasum": "" }, "require": { - "php": "^7.0" + "php": "^7.1" }, "require-dev": { - "phpunit/phpunit": "^6.1" + "phpunit/phpunit": "^7.5" + }, + "suggest": { + "ext-posix": "*" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.1.x-dev" + "dev-master": "4.2-dev" } }, "autoload": { @@ -1178,7 +1133,7 @@ "environment", "hhvm" ], - "time": "2017-07-01T08:51:00+00:00" + "time": "2019-05-05T09:05:15+00:00" }, { "name": "sebastian/exporter", @@ -1445,25 +1400,25 @@ }, { "name": "sebastian/resource-operations", - "version": "1.0.0", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/resource-operations.git", - "reference": "ce990bb21759f94aeafd30209e8cfcdfa8bc3f52" + "reference": "4d7a795d35b889bf80a0cc04e08d77cedfa917a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/ce990bb21759f94aeafd30209e8cfcdfa8bc3f52", - "reference": "ce990bb21759f94aeafd30209e8cfcdfa8bc3f52", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/4d7a795d35b889bf80a0cc04e08d77cedfa917a9", + "reference": "4d7a795d35b889bf80a0cc04e08d77cedfa917a9", "shasum": "" }, "require": { - "php": ">=5.6.0" + "php": "^7.1" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "2.0-dev" } }, "autoload": { @@ -1483,7 +1438,7 @@ ], "description": "Provides a list of PHP built-in functions that operate on resources", "homepage": "https://www.github.com/sebastianbergmann/resource-operations", - "time": "2015-07-28T20:34:47+00:00" + "time": "2018-10-04T04:07:39+00:00" }, { "name": "sebastian/version", diff --git a/vendor-bin/robo/composer.lock b/vendor-bin/robo/composer.lock index 2c7301c..ad49752 100644 --- a/vendor-bin/robo/composer.lock +++ b/vendor-bin/robo/composer.lock @@ -1092,25 +1092,27 @@ }, { "name": "symfony/console", - "version": "v3.4.28", + "version": "v4.3.1", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "8e1d1e406dd31727fa70cd5a99cda202e9d6a5c6" + "reference": "d50bbeeb0e17e6dd4124ea391eff235e932cbf64" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/8e1d1e406dd31727fa70cd5a99cda202e9d6a5c6", - "reference": "8e1d1e406dd31727fa70cd5a99cda202e9d6a5c6", + "url": "https://api.github.com/repos/symfony/console/zipball/d50bbeeb0e17e6dd4124ea391eff235e932cbf64", + "reference": "d50bbeeb0e17e6dd4124ea391eff235e932cbf64", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", - "symfony/debug": "~2.8|~3.0|~4.0", - "symfony/polyfill-mbstring": "~1.0" + "php": "^7.1.3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php73": "^1.8", + "symfony/service-contracts": "^1.1" }, "conflict": { "symfony/dependency-injection": "<3.4", + "symfony/event-dispatcher": "<4.3", "symfony/process": "<3.3" }, "provide": { @@ -1118,11 +1120,12 @@ }, "require-dev": { "psr/log": "~1.0", - "symfony/config": "~3.3|~4.0", + "symfony/config": "~3.4|~4.0", "symfony/dependency-injection": "~3.4|~4.0", - "symfony/event-dispatcher": "~2.8|~3.0|~4.0", + "symfony/event-dispatcher": "^4.3", "symfony/lock": "~3.4|~4.0", - "symfony/process": "~3.3|~4.0" + "symfony/process": "~3.4|~4.0", + "symfony/var-dumper": "^4.3" }, "suggest": { "psr/log": "For using the console logger", @@ -1133,7 +1136,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -1160,41 +1163,55 @@ ], "description": "Symfony Console Component", "homepage": "https://symfony.com", - "time": "2019-05-09T08:42:51+00:00" + "time": "2019-06-05T13:25:51+00:00" }, { - "name": "symfony/debug", - "version": "v3.4.28", + "name": "symfony/event-dispatcher", + "version": "v4.3.1", "source": { "type": "git", - "url": "https://github.com/symfony/debug.git", - "reference": "671fc55bd14800668b1d0a3708c3714940e30a8c" + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "4e6c670af81c4fb0b6c08b035530a9915d0b691f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/debug/zipball/671fc55bd14800668b1d0a3708c3714940e30a8c", - "reference": "671fc55bd14800668b1d0a3708c3714940e30a8c", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/4e6c670af81c4fb0b6c08b035530a9915d0b691f", + "reference": "4e6c670af81c4fb0b6c08b035530a9915d0b691f", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", - "psr/log": "~1.0" + "php": "^7.1.3", + "symfony/event-dispatcher-contracts": "^1.1" }, "conflict": { - "symfony/http-kernel": ">=2.3,<2.3.24|~2.4.0|>=2.5,<2.5.9|>=2.6,<2.6.2" + "symfony/dependency-injection": "<3.4" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "1.1" }, "require-dev": { - "symfony/http-kernel": "~2.8|~3.0|~4.0" + "psr/log": "~1.0", + "symfony/config": "~3.4|~4.0", + "symfony/dependency-injection": "~3.4|~4.0", + "symfony/expression-language": "~3.4|~4.0", + "symfony/http-foundation": "^3.4|^4.0", + "symfony/service-contracts": "^1.1", + "symfony/stopwatch": "~3.4|~4.0" + }, + "suggest": { + "symfony/dependency-injection": "", + "symfony/http-kernel": "" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.3-dev" } }, "autoload": { "psr-4": { - "Symfony\\Component\\Debug\\": "" + "Symfony\\Component\\EventDispatcher\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -1214,54 +1231,41 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony Debug Component", + "description": "Symfony EventDispatcher Component", "homepage": "https://symfony.com", - "time": "2019-05-18T13:32:47+00:00" + "time": "2019-05-30T16:10:05+00:00" }, { - "name": "symfony/event-dispatcher", - "version": "v3.4.28", + "name": "symfony/event-dispatcher-contracts", + "version": "v1.1.5", "source": { "type": "git", - "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "a088aafcefb4eef2520a290ed82e4374092a6dff" + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c61766f4440ca687de1084a5c00b08e167a2575c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/a088aafcefb4eef2520a290ed82e4374092a6dff", - "reference": "a088aafcefb4eef2520a290ed82e4374092a6dff", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c61766f4440ca687de1084a5c00b08e167a2575c", + "reference": "c61766f4440ca687de1084a5c00b08e167a2575c", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" - }, - "conflict": { - "symfony/dependency-injection": "<3.3" - }, - "require-dev": { - "psr/log": "~1.0", - "symfony/config": "~2.8|~3.0|~4.0", - "symfony/dependency-injection": "~3.3|~4.0", - "symfony/expression-language": "~2.8|~3.0|~4.0", - "symfony/stopwatch": "~2.8|~3.0|~4.0" + "php": "^7.1.3" }, "suggest": { - "symfony/dependency-injection": "", - "symfony/http-kernel": "" + "psr/event-dispatcher": "", + "symfony/event-dispatcher-implementation": "" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "1.1-dev" } }, "autoload": { "psr-4": { - "Symfony\\Component\\EventDispatcher\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Symfony\\Contracts\\EventDispatcher\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1269,40 +1273,48 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony EventDispatcher Component", + "description": "Generic abstractions related to dispatching event", "homepage": "https://symfony.com", - "time": "2019-04-02T08:51:52+00:00" + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "time": "2019-06-20T06:46:26+00:00" }, { "name": "symfony/filesystem", - "version": "v3.4.28", + "version": "v4.3.1", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "acf99758b1df8e9295e6b85aa69f294565c9fedb" + "reference": "bf2af40d738dec5e433faea7b00daa4431d0a4cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/acf99758b1df8e9295e6b85aa69f294565c9fedb", - "reference": "acf99758b1df8e9295e6b85aa69f294565c9fedb", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/bf2af40d738dec5e433faea7b00daa4431d0a4cf", + "reference": "bf2af40d738dec5e433faea7b00daa4431d0a4cf", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", + "php": "^7.1.3", "symfony/polyfill-ctype": "~1.8" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -1329,29 +1341,29 @@ ], "description": "Symfony Filesystem Component", "homepage": "https://symfony.com", - "time": "2019-02-04T21:34:32+00:00" + "time": "2019-06-03T20:27:40+00:00" }, { "name": "symfony/finder", - "version": "v3.4.28", + "version": "v4.3.1", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "fa5d962a71f2169dfe1cbae217fa5a2799859f6c" + "reference": "b3d4f4c0e4eadfdd8b296af9ca637cfbf51d8176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/fa5d962a71f2169dfe1cbae217fa5a2799859f6c", - "reference": "fa5d962a71f2169dfe1cbae217fa5a2799859f6c", + "url": "https://api.github.com/repos/symfony/finder/zipball/b3d4f4c0e4eadfdd8b296af9ca637cfbf51d8176", + "reference": "b3d4f4c0e4eadfdd8b296af9ca637cfbf51d8176", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "php": "^7.1.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -1378,7 +1390,7 @@ ], "description": "Symfony Finder Component", "homepage": "https://symfony.com", - "time": "2019-05-24T12:25:55+00:00" + "time": "2019-05-26T20:47:49+00:00" }, { "name": "symfony/polyfill-ctype", @@ -1497,6 +1509,64 @@ ], "time": "2019-02-06T07:57:58+00:00" }, + { + "name": "symfony/polyfill-php73", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php73.git", + "reference": "d1fb4abcc0c47be136208ad9d68bf59f1ee17abd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/d1fb4abcc0c47be136208ad9d68bf59f1ee17abd", + "reference": "d1fb4abcc0c47be136208ad9d68bf59f1ee17abd", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.11-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Php73\\": "" + }, + "files": [ + "bootstrap.php" + ], + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "time": "2019-02-06T07:57:58+00:00" + }, { "name": "symfony/process", "version": "v3.4.28", @@ -1546,22 +1616,80 @@ "homepage": "https://symfony.com", "time": "2019-05-22T12:54:11+00:00" }, + { + "name": "symfony/service-contracts", + "version": "v1.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "f391a00de78ec7ec8cf5cdcdae59ec7b883edb8d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f391a00de78ec7ec8cf5cdcdae59ec7b883edb8d", + "reference": "f391a00de78ec7ec8cf5cdcdae59ec7b883edb8d", + "shasum": "" + }, + "require": { + "php": "^7.1.3", + "psr/container": "^1.0" + }, + "suggest": { + "symfony/service-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "time": "2019-06-13T11:15:36+00:00" + }, { "name": "symfony/yaml", - "version": "v3.4.28", + "version": "v4.3.1", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "212a27b731e5bfb735679d1ffaac82bd6a1dc996" + "reference": "c60ecf5ba842324433b46f58dc7afc4487dbab99" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/212a27b731e5bfb735679d1ffaac82bd6a1dc996", - "reference": "212a27b731e5bfb735679d1ffaac82bd6a1dc996", + "url": "https://api.github.com/repos/symfony/yaml/zipball/c60ecf5ba842324433b46f58dc7afc4487dbab99", + "reference": "c60ecf5ba842324433b46f58dc7afc4487dbab99", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", + "php": "^7.1.3", "symfony/polyfill-ctype": "~1.8" }, "conflict": { @@ -1576,7 +1704,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -1603,7 +1731,7 @@ ], "description": "Symfony Yaml Component", "homepage": "https://symfony.com", - "time": "2019-03-25T07:48:46+00:00" + "time": "2019-04-06T14:04:46+00:00" } ], "packages-dev": [], From 92b1626dbacbea77a40be243b7fe0619e058f285 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 21 Jun 2019 12:00:23 -0400 Subject: [PATCH 121/142] Remove most unused features of the query builder Experience has proved programmatically setting joins is not useful, and getting the types and values of query parts was not being maintained. The programmatic setting of GROUP BY may be useful in future, however. --- lib/Misc/Query.php | 71 ++++------------------------------------------ 1 file changed, 6 insertions(+), 65 deletions(-) diff --git a/lib/Misc/Query.php b/lib/Misc/Query.php index 5a1b0b8..55d2cac 100644 --- a/lib/Misc/Query.php +++ b/lib/Misc/Query.php @@ -13,10 +13,6 @@ class Query { protected $qCTE = []; // Common table expression query components protected $tCTE = []; // Common table expression type bindings protected $vCTE = []; // Common table expression binding values - protected $jCTE = []; // Common Table Expression joins - protected $qJoin = []; // JOIN clause components - protected $tJoin = []; // JOIN clause type bindings - protected $vJoin = []; // JOIN clause binding values protected $qWhere = []; // WHERE clause components protected $tWhere = []; // WHERE clause type bindings protected $vWhere = []; // WHERE clause binding values @@ -42,24 +38,12 @@ class Query { return true; } - public function setCTE(string $tableSpec, string $body, $types = null, $values = null, string $join = ''): bool { + public function setCTE(string $tableSpec, string $body, $types = null, $values = null): bool { $this->qCTE[] = "$tableSpec as ($body)"; if (!is_null($types)) { $this->tCTE[] = $types; $this->vCTE[] = $values; } - if (strlen($join)) { // the CTE might only participate in subqueries rather than a join on the main query - $this->jCTE[] = $join; - } - return true; - } - - public function setJoin(string $join, $types = null, $values = null): bool { - $this->qJoin[] = $join; - if (!is_null($types)) { - $this->tJoin[] = $types; - $this->vJoin[] = $values; - } return true; } @@ -88,12 +72,8 @@ class Query { return true; } - public function setOrder(string $order, bool $prepend = false): bool { - if ($prepend) { - array_unshift($this->order, $order); - } else { - $this->order[] = $order; - } + public function setOrder(string $order): bool { + $this->order[] = $order; return true; } @@ -103,11 +83,10 @@ class Query { return true; } - public function pushCTE(string $tableSpec, string $join = ''): bool { + public function pushCTE(string $tableSpec): bool { // this function takes the query body and converts it to a common table expression, putting it at the bottom of the existing CTE stack // all WHERE, ORDER BY, and LIMIT parts belong to the new CTE and are removed from the main query $this->setCTE($tableSpec, $this->buildQueryBody(), [$this->tBody, $this->tWhere, $this->tWhereNot], [$this->vBody, $this->vWhere, $this->vWhereNot]); - $this->jCTE = []; $this->tBody = []; $this->vBody = []; $this->qWhere = []; @@ -116,15 +95,9 @@ class Query { $this->qWhereNot = []; $this->tWhereNot = []; $this->vWhereNot = []; - $this->qJoin = []; - $this->tJoin = []; - $this->vJoin = []; $this->order = []; $this->group = []; $this->setLimit(0, 0); - if (strlen($join)) { - $this->jCTE[] = $join; - } return true; } @@ -144,49 +117,17 @@ class Query { } public function getTypes(): array { - return [$this->tCTE, $this->tBody, $this->tJoin, $this->tWhere, $this->tWhereNot]; + return [$this->tCTE, $this->tBody, $this->tWhere, $this->tWhereNot]; } public function getValues(): array { - return [$this->vCTE, $this->vBody, $this->vJoin, $this->vWhere, $this->vWhereNot]; - } - - public function getJoinTypes(): array { - return $this->tJoin; - } - - public function getJoinValues(): array { - return $this->vJoin; - } - - public function getWhereTypes(): array { - return $this->tWhere; - } - - public function getWhereValues(): array { - return $this->vWhere; - } - - public function getCTETypes(): array { - return $this->tCTE; - } - - public function getCTEValues(): array { - return $this->vCTE; + return [$this->vCTE, $this->vBody, $this->vWhere, $this->vWhereNot]; } protected function buildQueryBody(): string { $out = ""; // add the body $out .= $this->qBody; - if (sizeof($this->qCTE)) { - // add any joins against CTEs - $out .= " ".implode(" ", $this->jCTE); - } - // add any JOINs - if (sizeof($this->qJoin)) { - $out .= " ".implode(" ", $this->qJoin); - } // add any WHERE terms if (sizeof($this->qWhere) || sizeof($this->qWhereNot)) { $where = implode(" AND ", $this->qWhere); From 7046ce163c1dc9e13db3568126821cbf8b3eaa03 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 21 Jun 2019 13:47:34 -0400 Subject: [PATCH 122/142] More format-neutral code out of OPML class --- lib/ImportExport/AbstractImportExport.php | 167 ++++++++++++++++++ lib/ImportExport/OPML.php | 150 +--------------- .../{TestOPMLFile.php => TestFile.php} | 30 ++-- tests/phpunit.xml | 5 +- 4 files changed, 186 insertions(+), 166 deletions(-) create mode 100644 lib/ImportExport/AbstractImportExport.php rename tests/cases/ImportExport/{TestOPMLFile.php => TestFile.php} (85%) diff --git a/lib/ImportExport/AbstractImportExport.php b/lib/ImportExport/AbstractImportExport.php new file mode 100644 index 0000000..5445230 --- /dev/null +++ b/lib/ImportExport/AbstractImportExport.php @@ -0,0 +1,167 @@ +exists($user)) { + throw new UserException("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); + } + // first extract useful information from the input + list($feeds, $folders) = $this->parse($data, $flat); + $folderMap = []; + foreach ($folders as $f) { + // check to make sure folder names are all valid + if (!strlen(trim($f['name']))) { + throw new Exception("invalidFolderName"); + } + // check for duplicates + if (!isset($folderMap[$f['parent']])) { + $folderMap[$f['parent']] = []; + } + if (isset($folderMap[$f['parent']][$f['name']])) { + throw new Exception("invalidFolderCopy"); + } else { + $folderMap[$f['parent']][$f['name']] = true; + } + } + // get feed IDs for each URL, adding feeds where necessary + foreach ($feeds as $k => $f) { + $feeds[$k]['id'] = Arsse::$db->feedAdd(($f['url'])); + } + // start a transaction for atomic rollback + $tr = Arsse::$db->begin(); + // get current state of database + $foldersDb = iterator_to_array(Arsse::$db->folderList($user)); + $feedsDb = iterator_to_array(Arsse::$db->subscriptionList($user)); + $tagsDb = iterator_to_array(Arsse::$db->tagList($user)); + // reconcile folders + $folderMap = [0 => 0]; + foreach ($folders as $id => $f) { + $parent = $folderMap[$f['parent']]; + // find a match for the import folder in the existing folders + foreach ($foldersDb as $db) { + if ((int) $db['parent'] == $parent && $db['name'] === $f['name']) { + $folderMap[$id] = (int) $db['id']; + break; + } + } + if (!isset($folderMap[$id])) { + // if no existing folder exists, add one + $folderMap[$id] = Arsse::$db->folderAdd($user, ['name' => $f['name'], 'parent' -> $parent]); + } + } + // process newsfeed subscriptions + $feedMap = []; + $tagMap = []; + foreach ($feeds as $f) { + $folder = $folderMap[$f['folder']]; + $title = strlen(trim($f['title'])) ? $f['title'] : null; + $found = false; + // find a match for the import feed is existing subscriptions + foreach ($feedsDb as $db) { + if ((int) $db['feed'] == $f['id']) { + $found = true; + $feedMap[$f['id']] = (int) $db['id']; + break; + } + } + if (!$found) { + // if no subscription exists, add one + $feedMap[$f['id']] = Arsse::$db->subscriptionAdd($user, $f['url']); + } + if (!$found || $replace) { + // set the subscription's properties, if this is a new feed or we're doing a full replacement + Arsse::$db->subscriptionPropertiesSet($user, $feedMap[$f['id']], ['title' => $title, 'folder' => $folder]); + // compile the set of used tags, if this is a new feed or we're doing a full replacement + foreach ($f['tags'] as $t) { + if (!strlen(trim($t))) { + // ignore any blank tags + continue; + } + if (!isset($tagMap[$t])) { + // populate the tag map + $tagMap[$t] = []; + } + $tagMap[$t][] = $f['id']; + } + } + } + // set tags + $mode = $replace ? Database::ASSOC_REPLACE : Database::ASSOC_ADD; + foreach ($tagMap as $tag => $subs) { + // make sure the tag exists + $found = false; + foreach ($tagsDb as $db) { + if ($tag === $db['name']) { + $found = true; + break; + } + } + if (!$found) { + // add the tag if it wasn't found + Arsse::$db->tagAdd($user, ['name' => $tag]); + } + Arsse::$db->tagSubscriptionsSet($user, $tag, $subs, $mode, true); + } + // finally, if we're performing a replacement, delete any subscriptions, folders, or tags which were not present in the import + if ($replace) { + foreach (array_diff(array_column($feedsDb, "id"), $feedMap) as $id) { + try { + Arsse::$db->subscriptionRemove($user, $id); + } catch (InputException $e) { + // ignore errors + } + } + foreach (array_diff(array_column($foldersDb, "id"), $folderMap) as $id) { + try { + Arsse::$db->folderRemove($user, $id); + } catch (InputException $e) { + // ignore errors + } + } + foreach (array_diff(array_column($tagsDb, "name"), array_keys($tagMap)) as $id) { + try { + Arsse::$db->tagRemove($user, $id, true); + } catch (InputException $e) { + // ignore errors + } + } + } + $tr->commit(); + return true; + } + + abstract public function parse(string $data, bool $flat): array; + + abstract public function export(string $user, bool $flat = false): string; + + public function exportFile(string $file, string $user, bool $flat = false): bool { + $data = $this->export($user, $flat); + if (!@file_put_contents($file, $data)) { + // if it fails throw an exception + $err = file_exists($file) ? "fileUnwritable" : "fileUncreatable"; + throw new Exception($err, ['file' => $file, 'format' => str_replace(__NAMESPACE__."\\", "", get_class($this))]); + } + return true; + } + + public function importFile(string $file, string $user, bool $flat = false, bool $replace): bool { + $data = @file_get_contents($file); + if ($data === false) { + // if it fails throw an exception + $err = file_exists($file) ? "fileUnreadable" : "fileMissing"; + throw new Exception($err, ['file' => $file, 'format' => str_replace(__NAMESPACE__."\\", "", get_class($this))]); + } + return $this->import($user, $data, $flat, $replace); + } +} diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php index 5c633b4..9225269 100644 --- a/lib/ImportExport/OPML.php +++ b/lib/ImportExport/OPML.php @@ -7,137 +7,9 @@ declare(strict_types=1); namespace JKingWeb\Arsse\ImportExport; use JKingWeb\Arsse\Arsse; -use JKingWeb\Arsse\Database; -use JKingWeb\Arsse\Db\ExceptionInput as InputException; use JKingWeb\Arsse\User\Exception as UserException; -class OPML { - public function import(string $user, string $opml, bool $flat = false, bool $replace = false): bool { - // first extract useful information from the input - list($feeds, $folders) = $this->parse($opml, $flat); - $folderMap = []; - foreach ($folders as $f) { - // check to make sure folder names are all valid - if (!strlen(trim($f['name']))) { - throw new Exception("invalidFolderName"); - } - // check for duplicates - if (!isset($folderMap[$f['parent']])) { - $folderMap[$f['parent']] = []; - } - if (isset($folderMap[$f['parent']][$f['name']])) { - throw new Exception("invalidFolderCopy"); - } else { - $folderMap[$f['parent']][$f['name']] = true; - } - } - // get feed IDs for each URL, adding feeds where necessary - foreach ($feeds as $k => $f) { - $feeds[$k]['id'] = Arsse::$db->feedAdd(($f['url'])); - } - // start a transaction for atomic rollback - $tr = Arsse::$db->begin(); - // get current state of database - $foldersDb = iterator_to_array(Arsse::$db->folderList($user)); - $feedsDb = iterator_to_array(Arsse::$db->subscriptionList($user)); - $tagsDb = iterator_to_array(Arsse::$db->tagList($user)); - // reconcile folders - $folderMap = [0 => 0]; - foreach ($folders as $id => $f) { - $parent = $folderMap[$f['parent']]; - // find a match for the import folder in the existing folders - foreach ($foldersDb as $db) { - if ((int) $db['parent'] == $parent && $db['name'] === $f['name']) { - $folderMap[$id] = (int) $db['id']; - break; - } - } - if (!isset($folderMap[$id])) { - // if no existing folder exists, add one - $folderMap[$id] = Arsse::$db->folderAdd($user, ['name' => $f['name'], 'parent' -> $parent]); - } - } - // process newsfeed subscriptions - $feedMap = []; - $tagMap = []; - foreach ($feeds as $f) { - $folder = $folderMap[$f['folder']]; - $title = strlen(trim($f['title'])) ? $f['title'] : null; - $found = false; - // find a match for the import feed is existing subscriptions - foreach ($feedsDb as $db) { - if ((int) $db['feed'] == $f['id']) { - $found = true; - $feedMap[$f['id']] = (int) $db['id']; - break; - } - } - if (!$found) { - // if no subscription exists, add one - $feedMap[$f['id']] = Arsse::$db->subscriptionAdd($user, $f['url']); - } - if (!$found || $replace) { - // set the subscription's properties, if this is a new feed or we're doing a full replacement - Arsse::$db->subscriptionPropertiesSet($user, $feedMap[$f['id']], ['title' => $title, 'folder' => $folder]); - // compile the set of used tags, if this is a new feed or we're doing a full replacement - foreach ($f['tags'] as $t) { - if (!strlen(trim($t))) { - // ignore any blank tags - continue; - } - if (!isset($tagMap[$t])) { - // populate the tag map - $tagMap[$t] = []; - } - $tagMap[$t][] = $f['id']; - } - } - } - // set tags - $mode = $replace ? Database::ASSOC_REPLACE : Database::ASSOC_ADD; - foreach ($tagMap as $tag => $subs) { - // make sure the tag exists - $found = false; - foreach ($tagsDb as $db) { - if ($tag === $db['name']) { - $found = true; - break; - } - } - if (!$found) { - // add the tag if it wasn't found - Arsse::$db->tagAdd($user, ['name' => $tag]); - } - Arsse::$db->tagSubscriptionsSet($user, $tag, $subs, $mode, true); - } - // finally, if we're performing a replacement, delete any subscriptions, folders, or tags which were not present in the import - if ($replace) { - foreach (array_diff(array_column($feedsDb, "id"), $feedMap) as $id) { - try { - Arsse::$db->subscriptionRemove($user, $id); - } catch (InputException $e) { - // ignore errors - } - } - foreach (array_diff(array_column($foldersDb, "id"), $folderMap) as $id) { - try { - Arsse::$db->folderRemove($user, $id); - } catch (InputException $e) { - // ignore errors - } - } - foreach (array_diff(array_column($tagsDb, "name"), array_keys($tagMap)) as $id) { - try { - Arsse::$db->tagRemove($user, $id, true); - } catch (InputException $e) { - // ignore errors - } - } - } - $tr->commit(); - return true; - } - +class OPML extends AbstractImportExport { public function parse(string $opml, bool $flat): array { $d = new \DOMDocument; if (!@$d->loadXML($opml)) { @@ -276,24 +148,4 @@ class OPML { // return the serialization return $document->saveXML(); } - - public function exportFile(string $file, string $user, bool $flat = false): bool { - $data = $this->export($user, $flat); - if (!@file_put_contents($file, $data)) { - // if it fails throw an exception - $err = file_exists($file) ? "fileUnwritable" : "fileUncreatable"; - throw new Exception($err, ['file' => $file, 'format' => str_replace(__NAMESPACE__."\\", "", __CLASS__)]); - } - return true; - } - - public function importFile(string $file, string $user, bool $flat = false, bool $replace): bool { - $data = @file_get_contents($file); - if ($data === false) { - // if it fails throw an exception - $err = file_exists($file) ? "fileUnreadable" : "fileMissing"; - throw new Exception($err, ['file' => $file, 'format' => str_replace(__NAMESPACE__."\\", "", __CLASS__)]); - } - return $this->import($user, $data, $flat, $replace); - } } diff --git a/tests/cases/ImportExport/TestOPMLFile.php b/tests/cases/ImportExport/TestFile.php similarity index 85% rename from tests/cases/ImportExport/TestOPMLFile.php rename to tests/cases/ImportExport/TestFile.php index 35147ef..be2ebef 100644 --- a/tests/cases/ImportExport/TestOPMLFile.php +++ b/tests/cases/ImportExport/TestFile.php @@ -10,24 +10,24 @@ use JKingWeb\Arsse\ImportExport\OPML; use JKingWeb\Arsse\ImportExport\Exception; use org\bovigo\vfs\vfsStream; -/** @covers \JKingWeb\Arsse\ImportExport\OPML */ -class TestOPMLFile extends \JKingWeb\Arsse\Test\AbstractTest { +/** @covers \JKingWeb\Arsse\ImportExport\AbstractImportExport */ +class TestFile extends \JKingWeb\Arsse\Test\AbstractTest { protected $vfs; protected $path; - protected $opml; + protected $proc; public function setUp() { self::clearData(); // create a mock OPML processor with stubbed underlying import/export routines - $this->opml = \Phake::partialMock(OPML::class); - \Phake::when($this->opml)->export->thenReturn("OPML_FILE"); - \Phake::when($this->opml)->import->thenReturn(true); + $this->proc = \Phake::partialMock(OPML::class); + \Phake::when($this->proc)->export->thenReturn("EXPORT_FILE"); + \Phake::when($this->proc)->import->thenReturn(true); $this->vfs = vfsStream::setup("root", null, [ 'exportGoodFile' => "", 'exportGoodDir' => [], 'exportBadFile' => "", 'exportBadDir' => [], - 'importGoodFile' => "", + 'importGoodFile' => "GOOD_FILE", 'importBadFile' => "", ]); $this->path = $this->vfs->url()."/"; @@ -40,7 +40,7 @@ class TestOPMLFile extends \JKingWeb\Arsse\Test\AbstractTest { public function tearDown() { $this->path = null; $this->vfs = null; - $this->opml = null; + $this->proc = null; self::clearData(); } @@ -50,13 +50,13 @@ class TestOPMLFile extends \JKingWeb\Arsse\Test\AbstractTest { try { if ($exp instanceof \JKingWeb\Arsse\AbstractException) { $this->assertException($exp); - $this->opml->exportFile($path, $user, $flat); + $this->proc->exportFile($path, $user, $flat); } else { - $this->assertSame($exp, $this->opml->exportFile($path, $user, $flat)); - $this->assertSame("OPML_FILE", $this->vfs->getChild($file)->getContent()); + $this->assertSame($exp, $this->proc->exportFile($path, $user, $flat)); + $this->assertSame("EXPORT_FILE", $this->vfs->getChild($file)->getContent()); } } finally { - \Phake::verify($this->opml)->export($user, $flat); + \Phake::verify($this->proc)->export($user, $flat); } } @@ -89,12 +89,12 @@ class TestOPMLFile extends \JKingWeb\Arsse\Test\AbstractTest { try { if ($exp instanceof \JKingWeb\Arsse\AbstractException) { $this->assertException($exp); - $this->opml->importFile($path, $user, $flat, $replace); + $this->proc->importFile($path, $user, $flat, $replace); } else { - $this->assertSame($exp, $this->opml->importFile($path, $user, $flat, $replace)); + $this->assertSame($exp, $this->proc->importFile($path, $user, $flat, $replace)); } } finally { - \Phake::verify($this->opml, \Phake::times((int) ($exp === true)))->import($user, "", $flat, $replace); + \Phake::verify($this->proc, \Phake::times((int) ($exp === true)))->import($user, "GOOD_FILE", $flat, $replace); } } diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 6ad94f3..fb753f3 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -7,7 +7,8 @@ convertWarningsToExceptions="false" beStrictAboutTestsThatDoNotTestAnything="true" beStrictAboutOutputDuringTests="true" - stopOnError="true"> + forceCoversAnnotation="true" +> @@ -114,8 +115,8 @@ cases/CLI/TestCLI.php + cases/ImportExport/TestFile.php cases/ImportExport/TestOPML.php - cases/ImportExport/TestOPMLFile.php From 12ef3e649fc19f01ca9ba80c4669b99ce3b7619c Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 21 Jun 2019 13:55:49 -0400 Subject: [PATCH 123/142] Mock AbstractImportExport directly --- tests/cases/ImportExport/TestFile.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/cases/ImportExport/TestFile.php b/tests/cases/ImportExport/TestFile.php index be2ebef..5a85bb6 100644 --- a/tests/cases/ImportExport/TestFile.php +++ b/tests/cases/ImportExport/TestFile.php @@ -6,7 +6,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\ImportExport; -use JKingWeb\Arsse\ImportExport\OPML; +use JKingWeb\Arsse\ImportExport\AbstractImportExport; use JKingWeb\Arsse\ImportExport\Exception; use org\bovigo\vfs\vfsStream; @@ -18,8 +18,8 @@ class TestFile extends \JKingWeb\Arsse\Test\AbstractTest { public function setUp() { self::clearData(); - // create a mock OPML processor with stubbed underlying import/export routines - $this->proc = \Phake::partialMock(OPML::class); + // create a mock Import/Export processor with stubbed underlying import/export routines + $this->proc = \Phake::partialMock(AbstractImportExport::class); \Phake::when($this->proc)->export->thenReturn("EXPORT_FILE"); \Phake::when($this->proc)->import->thenReturn(true); $this->vfs = vfsStream::setup("root", null, [ @@ -45,7 +45,7 @@ class TestFile extends \JKingWeb\Arsse\Test\AbstractTest { } /** @dataProvider provideFileExports */ - public function testExportOpmlToAFile(string $file, string $user, bool $flat, $exp) { + public function testExportToAFile(string $file, string $user, bool $flat, $exp) { $path = $this->path.$file; try { if ($exp instanceof \JKingWeb\Arsse\AbstractException) { @@ -84,7 +84,7 @@ class TestFile extends \JKingWeb\Arsse\Test\AbstractTest { } /** @dataProvider provideFileImports */ - public function testImportFromOpmlFile(string $file, string $user, bool $flat, bool $replace, $exp) { + public function testImportFromAFile(string $file, string $user, bool $flat, bool $replace, $exp) { $path = $this->path.$file; try { if ($exp instanceof \JKingWeb\Arsse\AbstractException) { From 2628ff7bf4589eb52de33e97228ff5ff1c891415 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 21 Jun 2019 18:52:27 -0400 Subject: [PATCH 124/142] Make database test helpers generic --- tests/cases/Database/Base.php | 123 +------------------- tests/cases/Database/SeriesArticle.php | 58 ++++----- tests/cases/Database/SeriesCleanup.php | 16 +-- tests/cases/Database/SeriesFeed.php | 8 +- tests/cases/Database/SeriesFolder.php | 12 +- tests/cases/Database/SeriesLabel.php | 26 ++--- tests/cases/Database/SeriesMeta.php | 10 +- tests/cases/Database/SeriesSession.php | 6 +- tests/cases/Database/SeriesSubscription.php | 16 +-- tests/cases/Database/SeriesTag.php | 26 ++--- tests/cases/Database/SeriesToken.php | 12 +- tests/cases/Database/SeriesUser.php | 6 +- tests/lib/AbstractTest.php | 121 ++++++++++++++++++- 13 files changed, 218 insertions(+), 222 deletions(-) diff --git a/tests/cases/Database/Base.php b/tests/cases/Database/Base.php index de6d39e..537002f 100644 --- a/tests/cases/Database/Base.php +++ b/tests/cases/Database/Base.php @@ -8,11 +8,7 @@ namespace JKingWeb\Arsse\TestCase\Database; use JKingWeb\Arsse\Test\Database; use JKingWeb\Arsse\Arsse; -use JKingWeb\Arsse\Conf; use JKingWeb\Arsse\User; -use JKingWeb\Arsse\Misc\ValueInfo; -use JKingWeb\Arsse\Db\Result; -use JKingWeb\Arsse\Test\DatabaseInformation; use Phake; abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest { @@ -84,7 +80,7 @@ abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest { $this->$setUp(); // prime the database with series data if it hasn't already been done if (!$this->primed && isset($this->data)) { - $this->primeDatabase($this->data); + $this->primeDatabase(static::$drv, $this->data); } } @@ -111,121 +107,4 @@ abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest { static::$failureReason = ""; static::clearData(); } - - public function primeDatabase(array $data): bool { - $drv = static::$drv; - $tr = $drv->begin(); - foreach ($data as $table => $info) { - $cols = array_map(function($v) { - return '"'.str_replace('"', '""', $v).'"'; - }, array_keys($info['columns'])); - $cols = implode(",", $cols); - $bindings = array_values($info['columns']); - $params = implode(",", array_fill(0, sizeof($info['columns']), "?")); - $s = $drv->prepareArray("INSERT INTO $table($cols) values($params)", $bindings); - foreach ($info['rows'] as $row) { - $s->runArray($row); - } - } - $tr->commit(); - $this->primed = true; - return true; - } - - public function compareExpectations(array $expected): bool { - foreach ($expected as $table => $info) { - $cols = array_map(function($v) { - return '"'.str_replace('"', '""', $v).'"'; - }, array_keys($info['columns'])); - $cols = implode(",", $cols); - $types = $info['columns']; - $data = static::$drv->prepare("SELECT $cols from $table")->run()->getAll(); - $cols = array_keys($info['columns']); - foreach ($info['rows'] as $index => $row) { - $this->assertCount(sizeof($cols), $row, "The number of values for array index $index does not match the number of fields"); - $row = array_combine($cols, $row); - foreach ($data as $index => $test) { - foreach ($test as $col => $value) { - switch ($types[$col]) { - case "datetime": - $test[$col] = $this->approximateTime($row[$col], $value); - break; - case "int": - $test[$col] = ValueInfo::normalize($value, ValueInfo::T_INT | ValueInfo::M_DROP | valueInfo::M_NULL); - break; - case "float": - $test[$col] = ValueInfo::normalize($value, ValueInfo::T_FLOAT | ValueInfo::M_DROP | valueInfo::M_NULL); - break; - case "bool": - $test[$col] = (int) ValueInfo::normalize($value, ValueInfo::T_BOOL | ValueInfo::M_DROP | valueInfo::M_NULL); - break; - } - } - if ($row===$test) { - $data[$index] = $test; - break; - } - } - $this->assertContains($row, $data, "Table $table does not contain record at array index $index."); - $found = array_search($row, $data, true); - unset($data[$found]); - } - $this->assertSame([], $data); - } - return true; - } - - public function primeExpectations(array $source, array $tableSpecs = null): array { - $out = []; - foreach ($tableSpecs as $table => $columns) { - // make sure the source has the table we want - $this->assertArrayHasKey($table, $source, "Source for expectations does not contain requested table $table."); - $out[$table] = [ - 'columns' => [], - 'rows' => array_fill(0, sizeof($source[$table]['rows']), []), - ]; - // make sure the source has all the columns we want for the table - $cols = array_flip($columns); - $cols = array_intersect_key($cols, $source[$table]['columns']); - $this->assertSame(array_keys($cols), $columns, "Source for table $table does not contain all requested columns"); - // get a map of source value offsets and keys - $targets = array_flip(array_keys($source[$table]['columns'])); - foreach ($cols as $key => $order) { - // fill the column-spec - $out[$table]['columns'][$key] = $source[$table]['columns'][$key]; - foreach ($source[$table]['rows'] as $index => $row) { - // fill each row column-wise with re-ordered values - $out[$table]['rows'][$index][$order] = $row[$targets[$key]]; - } - } - } - return $out; - } - - public function assertResult(array $expected, Result $data) { - $data = $data->getAll(); - $this->assertCount(sizeof($expected), $data, "Number of result rows (".sizeof($data).") differs from number of expected rows (".sizeof($expected).")"); - if (sizeof($expected)) { - // make sure the expectations are consistent - foreach ($expected as $exp) { - if (!isset($keys)) { - $keys = $exp; - continue; - } - $this->assertSame(array_keys($keys), array_keys($exp), "Result set expectations are irregular"); - } - // filter the result set to contain just the desired keys (we don't care if the result has extra keys) - $rows = []; - foreach ($data as $row) { - $rows[] = array_intersect_key($row, $keys); - } - // compare the result set to the expectations - foreach ($rows as $row) { - $this->assertContains($row, $expected, "Result set contains unexpected record."); - $found = array_search($row, $expected); - unset($expected[$found]); - } - $this->assertArraySubset($expected, [], false, "Expectations not in result set."); - } - } } diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 048cd18..2deaeb7 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -581,7 +581,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][9][4] = $now; $state['arsse_marks']['rows'][11][2] = 0; $state['arsse_marks']['rows'][11][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAllArticlesRead() { @@ -596,7 +596,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][] = [13,6,1,0,$now,'']; $state['arsse_marks']['rows'][] = [14,7,1,0,$now,'']; $state['arsse_marks']['rows'][] = [14,8,1,0,$now,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAllArticlesUnstarred() { @@ -607,7 +607,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][10][4] = $now; $state['arsse_marks']['rows'][11][3] = 0; $state['arsse_marks']['rows'][11][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAllArticlesStarred() { @@ -622,7 +622,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][] = [13,6,0,1,$now,'']; $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'']; $state['arsse_marks']['rows'][] = [14,8,0,1,$now,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAllArticlesUnreadAndUnstarred() { @@ -636,7 +636,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][11][2] = 0; $state['arsse_marks']['rows'][11][3] = 0; $state['arsse_marks']['rows'][11][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAllArticlesReadAndStarred() { @@ -654,7 +654,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][] = [13,6,1,1,$now,'']; $state['arsse_marks']['rows'][] = [14,7,1,1,$now,'']; $state['arsse_marks']['rows'][] = [14,8,1,1,$now,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAllArticlesUnreadAndStarred() { @@ -672,7 +672,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][] = [13,6,0,1,$now,'']; $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'']; $state['arsse_marks']['rows'][] = [14,8,0,1,$now,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAllArticlesReadAndUnstarred() { @@ -690,7 +690,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][] = [13,6,1,0,$now,'']; $state['arsse_marks']['rows'][] = [14,7,1,0,$now,'']; $state['arsse_marks']['rows'][] = [14,8,1,0,$now,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testSetNoteForAllArticles() { @@ -709,7 +709,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][] = [13,6,0,0,$now,'New note']; $state['arsse_marks']['rows'][] = [14,7,0,0,$now,'New note']; $state['arsse_marks']['rows'][] = [14,8,0,0,$now,'New note']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkATreeFolder() { @@ -720,7 +720,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][] = [13,6,1,0,$now,'']; $state['arsse_marks']['rows'][] = [14,7,1,0,$now,'']; $state['arsse_marks']['rows'][] = [14,8,1,0,$now,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkALeafFolder() { @@ -729,7 +729,7 @@ trait SeriesArticle { $state = $this->primeExpectations($this->data, $this->checkTables); $state['arsse_marks']['rows'][] = [13,5,1,0,$now,'']; $state['arsse_marks']['rows'][] = [13,6,1,0,$now,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAMissingFolder() { @@ -743,7 +743,7 @@ trait SeriesArticle { $state = $this->primeExpectations($this->data, $this->checkTables); $state['arsse_marks']['rows'][] = [13,5,1,0,$now,'']; $state['arsse_marks']['rows'][] = [13,6,1,0,$now,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAMissingSubscription() { @@ -757,7 +757,7 @@ trait SeriesArticle { $state = $this->primeExpectations($this->data, $this->checkTables); $state['arsse_marks']['rows'][9][3] = 1; $state['arsse_marks']['rows'][9][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkMultipleArticles() { @@ -767,7 +767,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][9][3] = 1; $state['arsse_marks']['rows'][9][4] = $now; $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkMultipleArticlessUnreadAndStarred() { @@ -780,7 +780,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][11][2] = 0; $state['arsse_marks']['rows'][11][4] = $now; $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkTooFewMultipleArticles() { @@ -803,7 +803,7 @@ trait SeriesArticle { $state = $this->primeExpectations($this->data, $this->checkTables); $state['arsse_marks']['rows'][9][3] = 1; $state['arsse_marks']['rows'][9][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkMultipleEditions() { @@ -813,13 +813,13 @@ trait SeriesArticle { $state['arsse_marks']['rows'][9][3] = 1; $state['arsse_marks']['rows'][9][4] = $now; $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkMultipleMissingEditions() { $this->assertSame(0, Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->editions([500,501]))); $state = $this->primeExpectations($this->data, $this->checkTables); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkMultipleEditionsUnread() { @@ -830,7 +830,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][9][4] = $now; $state['arsse_marks']['rows'][11][2] = 0; $state['arsse_marks']['rows'][11][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkMultipleEditionsUnreadWithStale() { @@ -839,7 +839,7 @@ trait SeriesArticle { $state = $this->primeExpectations($this->data, $this->checkTables); $state['arsse_marks']['rows'][11][2] = 0; $state['arsse_marks']['rows'][11][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkMultipleEditionsUnreadAndStarredWithStale() { @@ -851,7 +851,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][11][2] = 0; $state['arsse_marks']['rows'][11][4] = $now; $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkTooFewMultipleEditions() { @@ -866,7 +866,7 @@ trait SeriesArticle { public function testMarkAStaleEditionUnread() { Arsse::$db->articleMark($this->user, ['read'=>false], (new Context)->edition(20)); // no changes occur $state = $this->primeExpectations($this->data, $this->checkTables); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAStaleEditionStarred() { @@ -875,7 +875,7 @@ trait SeriesArticle { $state = $this->primeExpectations($this->data, $this->checkTables); $state['arsse_marks']['rows'][9][3] = 1; $state['arsse_marks']['rows'][9][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAStaleEditionUnreadAndStarred() { @@ -884,13 +884,13 @@ trait SeriesArticle { $state = $this->primeExpectations($this->data, $this->checkTables); $state['arsse_marks']['rows'][9][3] = 1; $state['arsse_marks']['rows'][9][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAStaleEditionUnreadAndUnstarred() { Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>false], (new Context)->edition(20)); // no changes occur $state = $this->primeExpectations($this->data, $this->checkTables); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAMissingEdition() { @@ -906,7 +906,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][8][4] = $now; $state['arsse_marks']['rows'][9][3] = 1; $state['arsse_marks']['rows'][9][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkByLatestEdition() { @@ -919,7 +919,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][] = [13,6,0,1,$now,'']; $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'']; $state['arsse_marks']['rows'][] = [14,8,0,1,$now,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkByLastMarked() { @@ -930,7 +930,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][8][4] = $now; $state['arsse_marks']['rows'][9][3] = 1; $state['arsse_marks']['rows'][9][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkByNotLastMarked() { @@ -939,7 +939,7 @@ trait SeriesArticle { $state = $this->primeExpectations($this->data, $this->checkTables); $state['arsse_marks']['rows'][] = [13,5,0,1,$now,'']; $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkArticlesWithoutAuthority() { diff --git a/tests/cases/Database/SeriesCleanup.php b/tests/cases/Database/SeriesCleanup.php index 6d80a7e..9d2e009 100644 --- a/tests/cases/Database/SeriesCleanup.php +++ b/tests/cases/Database/SeriesCleanup.php @@ -161,7 +161,7 @@ trait SeriesCleanup { $state['arsse_feeds']['rows'][0][1] = null; unset($state['arsse_feeds']['rows'][1]); $state['arsse_feeds']['rows'][2][1] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testCleanUpOrphanedFeedsWithUnlimitedRetention() { @@ -175,7 +175,7 @@ trait SeriesCleanup { ]); $state['arsse_feeds']['rows'][0][1] = null; $state['arsse_feeds']['rows'][2][1] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testCleanUpOldArticlesWithStandardRetention() { @@ -186,7 +186,7 @@ trait SeriesCleanup { foreach ([7,8,9] as $id) { unset($state['arsse_articles']['rows'][$id - 1]); } - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testCleanUpOldArticlesWithUnlimitedReadRetention() { @@ -200,7 +200,7 @@ trait SeriesCleanup { foreach ([7,8] as $id) { unset($state['arsse_articles']['rows'][$id - 1]); } - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testCleanUpOldArticlesWithUnlimitedUnreadRetention() { @@ -214,7 +214,7 @@ trait SeriesCleanup { foreach ([9] as $id) { unset($state['arsse_articles']['rows'][$id - 1]); } - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testCleanUpOldArticlesWithUnlimitedRetention() { @@ -226,7 +226,7 @@ trait SeriesCleanup { $state = $this->primeExpectations($this->data, [ 'arsse_articles' => ["id"] ]); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testCleanUpExpiredSessions() { @@ -237,7 +237,7 @@ trait SeriesCleanup { foreach ([3,4,5] as $id) { unset($state['arsse_sessions']['rows'][$id - 1]); } - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testCleanUpExpiredTokens() { @@ -248,6 +248,6 @@ trait SeriesCleanup { foreach ([2] as $id) { unset($state['arsse_tokens']['rows'][$id - 1]); } - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } } diff --git a/tests/cases/Database/SeriesFeed.php b/tests/cases/Database/SeriesFeed.php index a01f064..8576bdf 100644 --- a/tests/cases/Database/SeriesFeed.php +++ b/tests/cases/Database/SeriesFeed.php @@ -204,7 +204,7 @@ trait SeriesFeed { $state['arsse_marks']['rows'][3] = [6,4,0,0,$now]; $state['arsse_marks']['rows'][6] = [1,3,0,0,$now]; $state['arsse_feeds']['rows'][0] = [1,6]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); // update a valid feed which previously had an error Arsse::$db->feedUpdate(2); // update an erroneous feed which previously had no errors @@ -214,12 +214,12 @@ trait SeriesFeed { ]); $state['arsse_feeds']['rows'][1] = [2,0,""]; $state['arsse_feeds']['rows'][2] = [3,1,'Feed URL "http://localhost:8000/Feed/Fetching/Error?code=404" is invalid']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); // update the bad feed again, twice Arsse::$db->feedUpdate(3); Arsse::$db->feedUpdate(3); $state['arsse_feeds']['rows'][2] = [3,3,'Feed URL "http://localhost:8000/Feed/Fetching/Error?code=404" is invalid']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testUpdateAMissingFeed() { @@ -254,7 +254,7 @@ trait SeriesFeed { ["Bodybuilders"], ["Men"], ]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testListStaleFeeds() { diff --git a/tests/cases/Database/SeriesFolder.php b/tests/cases/Database/SeriesFolder.php index 9643b64..367c024 100644 --- a/tests/cases/Database/SeriesFolder.php +++ b/tests/cases/Database/SeriesFolder.php @@ -105,7 +105,7 @@ trait SeriesFolder { Phake::verify(Arsse::$user)->authorize($user, "folderAdd"); $state = $this->primeExpectations($this->data, ['arsse_folders' => ['id','owner', 'parent', 'name']]); $state['arsse_folders']['rows'][] = [$folderID, $user, null, "Entertainment"]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testAddADuplicateRootFolder() { @@ -120,7 +120,7 @@ trait SeriesFolder { Phake::verify(Arsse::$user)->authorize($user, "folderAdd"); $state = $this->primeExpectations($this->data, ['arsse_folders' => ['id','owner', 'parent', 'name']]); $state['arsse_folders']['rows'][] = [$folderID, $user, 2, "GNOME"]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testAddANestedFolderToAMissingParent() { @@ -218,7 +218,7 @@ trait SeriesFolder { Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "folderRemove"); $state = $this->primeExpectations($this->data, ['arsse_folders' => ['id','owner', 'parent', 'name']]); array_pop($state['arsse_folders']['rows']); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testRemoveAFolderTree() { @@ -228,7 +228,7 @@ trait SeriesFolder { foreach ([0,1,2,5] as $index) { unset($state['arsse_folders']['rows'][$index]); } - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testRemoveAMissingFolder() { @@ -292,7 +292,7 @@ trait SeriesFolder { Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "folderPropertiesSet"); $state = $this->primeExpectations($this->data, ['arsse_folders' => ['id','owner', 'parent', 'name']]); $state['arsse_folders']['rows'][5][3] = "Opinion"; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testRenameTheRootFolder() { @@ -319,7 +319,7 @@ trait SeriesFolder { Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "folderPropertiesSet"); $state = $this->primeExpectations($this->data, ['arsse_folders' => ['id','owner', 'parent', 'name']]); $state['arsse_folders']['rows'][5][2] = 5; // parent should have changed - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMoveTheRootFolder() { diff --git a/tests/cases/Database/SeriesLabel.php b/tests/cases/Database/SeriesLabel.php index 1f11004..ec767e6 100644 --- a/tests/cases/Database/SeriesLabel.php +++ b/tests/cases/Database/SeriesLabel.php @@ -257,7 +257,7 @@ trait SeriesLabel { Phake::verify(Arsse::$user)->authorize($user, "labelAdd"); $state = $this->primeExpectations($this->data, $this->checkLabels); $state['arsse_labels']['rows'][] = [$labelID, $user, "Entertaining"]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testAddADuplicateLabel() { @@ -313,7 +313,7 @@ trait SeriesLabel { Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "labelRemove"); $state = $this->primeExpectations($this->data, $this->checkLabels); array_shift($state['arsse_labels']['rows']); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testRemoveALabelByName() { @@ -321,7 +321,7 @@ trait SeriesLabel { Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "labelRemove"); $state = $this->primeExpectations($this->data, $this->checkLabels); array_shift($state['arsse_labels']['rows']); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testRemoveAMissingLabel() { @@ -397,7 +397,7 @@ trait SeriesLabel { Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "labelPropertiesSet"); $state = $this->primeExpectations($this->data, $this->checkLabels); $state['arsse_labels']['rows'][0][2] = "Curious"; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testRenameALabelByName() { @@ -405,7 +405,7 @@ trait SeriesLabel { Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "labelPropertiesSet"); $state = $this->primeExpectations($this->data, $this->checkLabels); $state['arsse_labels']['rows'][0][2] = "Curious"; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testRenameALabelToTheEmptyString() { @@ -487,14 +487,14 @@ trait SeriesLabel { $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_label_members']['rows'][4][3] = 1; $state['arsse_label_members']['rows'][] = [1,2,1,1]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testClearALabelFromArticles() { Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([1,5]), Database::ASSOC_REMOVE); $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_label_members']['rows'][0][3] = 0; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testApplyALabelToArticlesByName() { @@ -502,26 +502,26 @@ trait SeriesLabel { $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_label_members']['rows'][4][3] = 1; $state['arsse_label_members']['rows'][] = [1,2,1,1]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testClearALabelFromArticlesByName() { Arsse::$db->labelArticlesSet("john.doe@example.com", "Interesting", (new Context)->articles([1,5]), Database::ASSOC_REMOVE, true); $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_label_members']['rows'][0][3] = 0; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testApplyALabelToNoArticles() { Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([10000])); $state = $this->primeExpectations($this->data, $this->checkMembers); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testClearALabelFromNoArticles() { Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([10000]), Database::ASSOC_REMOVE); $state = $this->primeExpectations($this->data, $this->checkMembers); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testReplaceArticlesOfALabel() { @@ -531,7 +531,7 @@ trait SeriesLabel { $state['arsse_label_members']['rows'][2][3] = 0; $state['arsse_label_members']['rows'][4][3] = 1; $state['arsse_label_members']['rows'][] = [1,2,1,1]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testPurgeArticlesOfALabel() { @@ -539,7 +539,7 @@ trait SeriesLabel { $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_label_members']['rows'][0][3] = 0; $state['arsse_label_members']['rows'][2][3] = 0; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testApplyALabelToArticlesWithoutAuthority() { diff --git a/tests/cases/Database/SeriesMeta.php b/tests/cases/Database/SeriesMeta.php index 538700a..485c715 100644 --- a/tests/cases/Database/SeriesMeta.php +++ b/tests/cases/Database/SeriesMeta.php @@ -28,7 +28,7 @@ trait SeriesMeta { // as far as tests are concerned the schema version is part of the expectations primed into the database array_unshift($this->data['arsse_meta']['rows'], ['schema_version', "".Database::SCHEMA_VERSION]); // but it's already been inserted by the driver, so we prime without it - $this->primeDatabase($dataBare); + $this->primeDatabase(static::$drv, $dataBare); } protected function tearDownSeriesMeta() { @@ -39,7 +39,7 @@ trait SeriesMeta { $this->assertTrue(Arsse::$db->metaSet("favourite", "Cygnus X-1")); $state = $this->primeExpectations($this->data, ['arsse_meta' => ['key','value']]); $state['arsse_meta']['rows'][] = ["favourite","Cygnus X-1"]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testAddANewTypedValue() { @@ -52,14 +52,14 @@ trait SeriesMeta { $state['arsse_meta']['rows'][] = ["true","1"]; $state['arsse_meta']['rows'][] = ["false","0"]; $state['arsse_meta']['rows'][] = ["millennium","2000-01-01 00:00:00"]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testChangeAnExistingValue() { $this->assertTrue(Arsse::$db->metaSet("album", "Hemispheres")); $state = $this->primeExpectations($this->data, ['arsse_meta' => ['key','value']]); $state['arsse_meta']['rows'][1][1] = "Hemispheres"; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testRemoveAValue() { @@ -67,7 +67,7 @@ trait SeriesMeta { $this->assertFalse(Arsse::$db->metaRemove("album")); $state = $this->primeExpectations($this->data, ['arsse_meta' => ['key','value']]); unset($state['arsse_meta']['rows'][1]); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testRetrieveAValue() { diff --git a/tests/cases/Database/SeriesSession.php b/tests/cases/Database/SeriesSession.php index 74a809c..ad9a45b 100644 --- a/tests/cases/Database/SeriesSession.php +++ b/tests/cases/Database/SeriesSession.php @@ -69,7 +69,7 @@ trait SeriesSession { // sessions near timeout should be refreshed automatically $state = $this->primeExpectations($this->data, ['arsse_sessions' => ["id", "created", "expires", "user"]]); $state['arsse_sessions']['rows'][3][2] = Date::transform(Date::add(Arsse::$conf->userSessionTimeout, $now), "sql"); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); // session resumption should not check authorization Phake::when(Arsse::$user)->authorize->thenReturn(false); $this->assertArraySubset($exp1, Arsse::$db->sessionResume("80fa94c1a11f11e78667001e673b2560")); @@ -96,7 +96,7 @@ trait SeriesSession { $now = time(); $state = $this->primeExpectations($this->data, ['arsse_sessions' => ["id", "created", "expires", "user"]]); $state['arsse_sessions']['rows'][] = [$id, Date::transform($now, "sql"), Date::transform(Date::add(Arsse::$conf->userSessionTimeout, $now), "sql"), $user]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testCreateASessionWithoutAuthority() { @@ -111,7 +111,7 @@ trait SeriesSession { $this->assertTrue(Arsse::$db->sessionDestroy($user, $id)); $state = $this->primeExpectations($this->data, ['arsse_sessions' => ["id", "created", "expires", "user"]]); unset($state['arsse_sessions']['rows'][0]); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); // destroying a session which does not exist is not an error $this->assertFalse(Arsse::$db->sessionDestroy($user, $id)); } diff --git a/tests/cases/Database/SeriesSubscription.php b/tests/cases/Database/SeriesSubscription.php index d65fb3e..be06be8 100644 --- a/tests/cases/Database/SeriesSubscription.php +++ b/tests/cases/Database/SeriesSubscription.php @@ -160,7 +160,7 @@ trait SeriesSubscription { 'arsse_subscriptions' => ['id','owner','feed'], ]); $state['arsse_subscriptions']['rows'][] = [$subID,$this->user,1]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testAddASubscriptionToANewFeed() { @@ -177,7 +177,7 @@ trait SeriesSubscription { ]); $state['arsse_feeds']['rows'][] = [$feedID,$url,"",""]; $state['arsse_subscriptions']['rows'][] = [$subID,$this->user,$feedID]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testAddASubscriptionToANewFeedViaDiscovery() { @@ -195,7 +195,7 @@ trait SeriesSubscription { ]); $state['arsse_feeds']['rows'][] = [$feedID,$discovered,"",""]; $state['arsse_subscriptions']['rows'][] = [$subID,$this->user,$feedID]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testAddASubscriptionToAnInvalidFeed() { @@ -211,7 +211,7 @@ trait SeriesSubscription { 'arsse_feeds' => ['id','url','username','password'], 'arsse_subscriptions' => ['id','owner','feed'], ]); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); $this->assertException("invalidUrl", "Feed"); throw $e; } @@ -238,7 +238,7 @@ trait SeriesSubscription { 'arsse_subscriptions' => ['id','owner','feed'], ]); array_shift($state['arsse_subscriptions']['rows']); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testRemoveAMissingSubscription() { @@ -377,15 +377,15 @@ trait SeriesSubscription { 'arsse_subscriptions' => ['id','owner','feed','title','folder','pinned','order_type'], ]); $state['arsse_subscriptions']['rows'][0] = [1,"john.doe@example.com",2,"Ook Ook",3,0,0]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); Arsse::$db->subscriptionPropertiesSet($this->user, 1, [ 'title' => null, ]); $state['arsse_subscriptions']['rows'][0] = [1,"john.doe@example.com",2,null,3,0,0]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); // making no changes is a valid result Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['unhinged' => true]); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMoveASubscriptionToAMissingFolder() { diff --git a/tests/cases/Database/SeriesTag.php b/tests/cases/Database/SeriesTag.php index b3ff4e4..ddd52cd 100644 --- a/tests/cases/Database/SeriesTag.php +++ b/tests/cases/Database/SeriesTag.php @@ -117,7 +117,7 @@ trait SeriesTag { Phake::verify(Arsse::$user)->authorize($user, "tagAdd"); $state = $this->primeExpectations($this->data, $this->checkTags); $state['arsse_tags']['rows'][] = [$tagID, $user, "Entertaining"]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testAddADuplicateTag() { @@ -173,7 +173,7 @@ trait SeriesTag { Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "tagRemove"); $state = $this->primeExpectations($this->data, $this->checkTags); array_shift($state['arsse_tags']['rows']); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testRemoveATagByName() { @@ -181,7 +181,7 @@ trait SeriesTag { Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "tagRemove"); $state = $this->primeExpectations($this->data, $this->checkTags); array_shift($state['arsse_tags']['rows']); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testRemoveAMissingTag() { @@ -255,7 +255,7 @@ trait SeriesTag { Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "tagPropertiesSet"); $state = $this->primeExpectations($this->data, $this->checkTags); $state['arsse_tags']['rows'][0][2] = "Curious"; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testRenameATagByName() { @@ -263,7 +263,7 @@ trait SeriesTag { Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "tagPropertiesSet"); $state = $this->primeExpectations($this->data, $this->checkTags); $state['arsse_tags']['rows'][0][2] = "Curious"; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testRenameATagToTheEmptyString() { @@ -345,14 +345,14 @@ trait SeriesTag { $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_tag_members']['rows'][1][2] = 1; $state['arsse_tag_members']['rows'][] = [1,4,1]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testClearATagFromSubscriptions() { Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [1,3], Database::ASSOC_REMOVE); $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_tag_members']['rows'][0][2] = 0; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testApplyATagToSubscriptionsByName() { @@ -360,26 +360,26 @@ trait SeriesTag { $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_tag_members']['rows'][1][2] = 1; $state['arsse_tag_members']['rows'][] = [1,4,1]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testClearATagFromSubscriptionsByName() { Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [1,3], Database::ASSOC_REMOVE, true); $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_tag_members']['rows'][0][2] = 0; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testApplyATagToNoSubscriptionsByName() { Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [], Database::ASSOC_ADD, true); $state = $this->primeExpectations($this->data, $this->checkMembers); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testClearATagFromNoSubscriptionsByName() { Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [], Database::ASSOC_REMOVE, true); $state = $this->primeExpectations($this->data, $this->checkMembers); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testReplaceSubscriptionsOfATag() { @@ -389,7 +389,7 @@ trait SeriesTag { $state['arsse_tag_members']['rows'][1][2] = 1; $state['arsse_tag_members']['rows'][2][2] = 0; $state['arsse_tag_members']['rows'][] = [1,4,1]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testPurgeSubscriptionsOfATag() { @@ -397,7 +397,7 @@ trait SeriesTag { $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_tag_members']['rows'][0][2] = 0; $state['arsse_tag_members']['rows'][2][2] = 0; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testApplyATagToSubscriptionsWithoutAuthority() { diff --git a/tests/cases/Database/SeriesToken.php b/tests/cases/Database/SeriesToken.php index ff85407..ef223df 100644 --- a/tests/cases/Database/SeriesToken.php +++ b/tests/cases/Database/SeriesToken.php @@ -87,13 +87,13 @@ trait SeriesToken { $state = $this->primeExpectations($this->data, ['arsse_tokens' => ["id", "class", "expires", "user"]]); $id = Arsse::$db->tokenCreate($user, "fever.login"); $state['arsse_tokens']['rows'][] = [$id, "fever.login", null, $user]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); $id = Arsse::$db->tokenCreate($user, "fever.login", null, new \DateTime("2020-01-01T00:00:00Z")); $state['arsse_tokens']['rows'][] = [$id, "fever.login", "2020-01-01 00:00:00", $user]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); Arsse::$db->tokenCreate($user, "fever.login", "token!", new \DateTime("2021-01-01T00:00:00Z")); $state['arsse_tokens']['rows'][] = ["token!", "fever.login", "2021-01-01 00:00:00", $user]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testCreateATokenForAMissingUser() { @@ -113,7 +113,7 @@ trait SeriesToken { $this->assertTrue(Arsse::$db->tokenRevoke($user, "fever.login", $id)); $state = $this->primeExpectations($this->data, ['arsse_tokens' => ["id", "expires", "user"]]); unset($state['arsse_tokens']['rows'][0]); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); // revoking a token which does not exist is not an error $this->assertFalse(Arsse::$db->tokenRevoke($user, "fever.login", $id)); } @@ -124,10 +124,10 @@ trait SeriesToken { $this->assertTrue(Arsse::$db->tokenRevoke($user, "fever.login")); unset($state['arsse_tokens']['rows'][0]); unset($state['arsse_tokens']['rows'][1]); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); $this->assertTrue(Arsse::$db->tokenRevoke($user, "class.class")); unset($state['arsse_tokens']['rows'][2]); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); // revoking tokens which do not exist is not an error $this->assertFalse(Arsse::$db->tokenRevoke($user, "unknown.class")); } diff --git a/tests/cases/Database/SeriesUser.php b/tests/cases/Database/SeriesUser.php index 8036bee..8395edc 100644 --- a/tests/cases/Database/SeriesUser.php +++ b/tests/cases/Database/SeriesUser.php @@ -36,7 +36,7 @@ trait SeriesUser { $this->assertFalse(Arsse::$db->userExists("jane.doe@example.org")); Phake::verify(Arsse::$user)->authorize("jane.doe@example.com", "userExists"); Phake::verify(Arsse::$user)->authorize("jane.doe@example.org", "userExists"); - $this->compareExpectations($this->data); + $this->compareExpectations(static::$drv, $this->data); } public function testCheckThatAUserExistsWithoutAuthority() { @@ -68,7 +68,7 @@ trait SeriesUser { Phake::verify(Arsse::$user)->authorize("john.doe@example.org", "userAdd"); $state = $this->primeExpectations($this->data, ['arsse_users' => ['id']]); $state['arsse_users']['rows'][] = ["john.doe@example.org"]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testAddAnExistingUser() { @@ -87,7 +87,7 @@ trait SeriesUser { Phake::verify(Arsse::$user)->authorize("admin@example.net", "userRemove"); $state = $this->primeExpectations($this->data, ['arsse_users' => ['id']]); array_shift($state['arsse_users']['rows']); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testRemoveAMissingUser() { diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php index f55ca9b..6b4de82 100644 --- a/tests/lib/AbstractTest.php +++ b/tests/lib/AbstractTest.php @@ -9,14 +9,15 @@ namespace JKingWeb\Arsse\Test; use JKingWeb\Arsse\Exception; use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Conf; -use JKingWeb\Arsse\CLI; +use JKingWeb\Arsse\Db\Driver; +use JKingWeb\Arsse\Db\Result; use JKingWeb\Arsse\Misc\Date; +use JKingWeb\Arsse\Misc\ValueInfo; use Psr\Http\Message\MessageInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; use Zend\Diactoros\Response\JsonResponse; -use Zend\Diactoros\Response\EmptyResponse; /** @coversNothing */ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { @@ -135,4 +136,120 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { } return $value; } + + public function primeDatabase(Driver $drv, array $data): bool { + $tr = $drv->begin(); + foreach ($data as $table => $info) { + $cols = array_map(function($v) { + return '"'.str_replace('"', '""', $v).'"'; + }, array_keys($info['columns'])); + $cols = implode(",", $cols); + $bindings = array_values($info['columns']); + $params = implode(",", array_fill(0, sizeof($info['columns']), "?")); + $s = $drv->prepareArray("INSERT INTO $table($cols) values($params)", $bindings); + foreach ($info['rows'] as $row) { + $s->runArray($row); + } + } + $tr->commit(); + $this->primed = true; + return true; + } + + public function compareExpectations(Driver $drv, array $expected): bool { + foreach ($expected as $table => $info) { + $cols = array_map(function($v) { + return '"'.str_replace('"', '""', $v).'"'; + }, array_keys($info['columns'])); + $cols = implode(",", $cols); + $types = $info['columns']; + $data = $drv->prepare("SELECT $cols from $table")->run()->getAll(); + $cols = array_keys($info['columns']); + foreach ($info['rows'] as $index => $row) { + $this->assertCount(sizeof($cols), $row, "The number of values for array index $index does not match the number of fields"); + $row = array_combine($cols, $row); + foreach ($data as $index => $test) { + foreach ($test as $col => $value) { + switch ($types[$col]) { + case "datetime": + $test[$col] = $this->approximateTime($row[$col], $value); + break; + case "int": + $test[$col] = ValueInfo::normalize($value, ValueInfo::T_INT | ValueInfo::M_DROP | valueInfo::M_NULL); + break; + case "float": + $test[$col] = ValueInfo::normalize($value, ValueInfo::T_FLOAT | ValueInfo::M_DROP | valueInfo::M_NULL); + break; + case "bool": + $test[$col] = (int) ValueInfo::normalize($value, ValueInfo::T_BOOL | ValueInfo::M_DROP | valueInfo::M_NULL); + break; + } + } + if ($row===$test) { + $data[$index] = $test; + break; + } + } + $this->assertContains($row, $data, "Table $table does not contain record at array index $index."); + $found = array_search($row, $data, true); + unset($data[$found]); + } + $this->assertSame([], $data); + } + return true; + } + + public function primeExpectations(array $source, array $tableSpecs = null): array { + $out = []; + foreach ($tableSpecs as $table => $columns) { + // make sure the source has the table we want + $this->assertArrayHasKey($table, $source, "Source for expectations does not contain requested table $table."); + $out[$table] = [ + 'columns' => [], + 'rows' => array_fill(0, sizeof($source[$table]['rows']), []), + ]; + // make sure the source has all the columns we want for the table + $cols = array_flip($columns); + $cols = array_intersect_key($cols, $source[$table]['columns']); + $this->assertSame(array_keys($cols), $columns, "Source for table $table does not contain all requested columns"); + // get a map of source value offsets and keys + $targets = array_flip(array_keys($source[$table]['columns'])); + foreach ($cols as $key => $order) { + // fill the column-spec + $out[$table]['columns'][$key] = $source[$table]['columns'][$key]; + foreach ($source[$table]['rows'] as $index => $row) { + // fill each row column-wise with re-ordered values + $out[$table]['rows'][$index][$order] = $row[$targets[$key]]; + } + } + } + return $out; + } + + public function assertResult(array $expected, Result $data) { + $data = $data->getAll(); + $this->assertCount(sizeof($expected), $data, "Number of result rows (".sizeof($data).") differs from number of expected rows (".sizeof($expected).")"); + if (sizeof($expected)) { + // make sure the expectations are consistent + foreach ($expected as $exp) { + if (!isset($keys)) { + $keys = $exp; + continue; + } + $this->assertSame(array_keys($keys), array_keys($exp), "Result set expectations are irregular"); + } + // filter the result set to contain just the desired keys (we don't care if the result has extra keys) + $rows = []; + foreach ($data as $row) { + $rows[] = array_intersect_key($row, $keys); + } + // compare the result set to the expectations + foreach ($rows as $row) { + $this->assertContains($row, $expected, "Result set contains unexpected record."); + $found = array_search($row, $expected); + unset($expected[$found]); + } + $this->assertArraySubset($expected, [], false, "Expectations not in result set."); + } + } } From cb71a9efd7a6fc1fbf57c821895879a65d5f02c7 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 22 Jun 2019 10:29:26 -0400 Subject: [PATCH 125/142] Make database connections for testing configurable --- tests/cases/Db/BaseStatement.php | 2 +- tests/lib/AbstractTest.php | 22 +++++++++++++--------- tests/phpunit.xml | 15 +++++++++++++++ 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/tests/cases/Db/BaseStatement.php b/tests/cases/Db/BaseStatement.php index cdc74a7..f62c3e8 100644 --- a/tests/cases/Db/BaseStatement.php +++ b/tests/cases/Db/BaseStatement.php @@ -68,7 +68,7 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest { /** @dataProvider provideBinaryBindings */ public function testHandleBinaryData($value, string $type, string $exp) { if (in_array(static::$implementation, ["PostgreSQL", "PDO PostgreSQL"])) { - $this->markTestSkipped("Correct handling of binary data with PostgreSQL and native MySQL is currently unknown"); + $this->markTestIncomplete("Correct handling of binary data with PostgreSQL is not currently implemented"); } if ($exp === "null") { $query = "SELECT (? is null) as pass"; diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php index 6b4de82..b1b79f1 100644 --- a/tests/lib/AbstractTest.php +++ b/tests/lib/AbstractTest.php @@ -43,15 +43,19 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { public static function setConf(array $conf = [], bool $force = true) { $defaults = [ - 'dbSQLite3File' => ":memory:", - 'dbSQLite3Timeout' => 0, - 'dbPostgreSQLUser' => "arsse_test", - 'dbPostgreSQLPass' => "arsse_test", - 'dbPostgreSQLDb' => "arsse_test", - 'dbPostgreSQLSchema' => "arsse_test", - 'dbMySQLUser' => "arsse_test", - 'dbMySQLPass' => "arsse_test", - 'dbMySQLDb' => "arsse_test", + 'dbSQLite3File' => ":memory:", + 'dbSQLite3Timeout' => 0, + 'dbPostgreSQLHost' => $_ENV['ARSSE_TEST_PGSQL_HOST'] ?: "", + 'dbPostgreSQLPort' => $_ENV['ARSSE_TEST_PGSQL_PORT'] ?: 5432, + 'dbPostgreSQLUser' => $_ENV['ARSSE_TEST_PGSQL_USER'] ?: "arsse_test", + 'dbPostgreSQLPass' => $_ENV['ARSSE_TEST_PGSQL_PASS'] ?: "arsse_test", + 'dbPostgreSQLDb' => $_ENV['ARSSE_TEST_PGSQL_DB'] ?: "arsse_test", + 'dbPostgreSQLSchema' => $_ENV['ARSSE_TEST_PGSQL_SCHEMA'] ?: "arsse_test", + 'dbMySQLHost' => $_ENV['ARSSE_TEST_MYSQL_HOST'] ?: "localhost", + 'dbMySQLPort' => $_ENV['ARSSE_TEST_MYSQL_PORT'] ?: 3306, + 'dbMySQLUser' => $_ENV['ARSSE_TEST_MYSQL_USER'] ?: "arsse_test", + 'dbMySQLPass' => $_ENV['ARSSE_TEST_MYSQL_PASS'] ?: "arsse_test", + 'dbMySQLDb' => $_ENV['ARSSE_TEST_MYSQL_DB'] ?: "arsse_test", ]; Arsse::$conf = (($force ? null : Arsse::$conf) ?? (new Conf))->import($defaults)->import($conf); } diff --git a/tests/phpunit.xml b/tests/phpunit.xml index fb753f3..5617ddb 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -10,6 +10,20 @@ forceCoversAnnotation="true" > + + + + + + + + + + + + + + ../lib @@ -116,6 +130,7 @@ cases/ImportExport/TestFile.php + cases/ImportExport/TestImportExport.php cases/ImportExport/TestOPML.php From 61fe673e206a8f2be1f5b1b713048f2e6ab346b5 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sun, 23 Jun 2019 18:45:24 -0400 Subject: [PATCH 126/142] Skeleton for import tests --- tests/cases/ImportExport/TestImportExport.php | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 tests/cases/ImportExport/TestImportExport.php diff --git a/tests/cases/ImportExport/TestImportExport.php b/tests/cases/ImportExport/TestImportExport.php new file mode 100644 index 0000000..9001b11 --- /dev/null +++ b/tests/cases/ImportExport/TestImportExport.php @@ -0,0 +1,97 @@ +proc = \Phake::partialMock(AbstractImportExport::class); + // initialize an SQLite memeory database + static::setConf(); + try { + $this->drv = Driver::create(); + } catch (\JKingWeb\Arsse\Db\Exception $e) { + $this->markTestSkipped("An SQLite database is required for this test"); + } + // create the database interface with the suitable driver and apply the latest schema + Arsse::$db = new Database($this->drv); + Arsse::$db->driverSchemaUpdate(); + $this->data = [ + 'arsse_users' => [ + 'columns' => [ + 'id' => 'str', + 'password' => 'str', + ], + 'rows' => [ + ], + ], + 'arsse_folders' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'parent' => "int", + 'name' => "str", + ], + 'rows' => [ + ], + ], + 'arsse_feeds' => [ + 'columns' => [ + 'id' => "int", + 'url' => "str", + 'title' => "str", + ], + 'rows' => [ + ], + ], + 'arsse_subscriptions' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'feed' => "int", + 'title' => "str", + ], + 'rows' => [ + ], + ], + 'arsse_tags' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'name' => "str", + ], + 'rows' => [ + ], + ], + 'arsse_tag_members' => [ + 'columns' => [ + 'tag' => "int", + 'subscription' => "int", + 'assigned' => "bool", + ], + 'rows' => [ + ], + ], + ]; + } + + public function tearDown() { + $this->drv = null; + $this->proc = null; + self::clearData(); + } +} From 30cede9ea4ec2cf891037d5e748394dab08b7717 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 5 Jul 2019 14:58:05 -0400 Subject: [PATCH 127/142] Make OPML parser protected --- lib/ImportExport/AbstractImportExport.php | 2 +- lib/ImportExport/OPML.php | 2 +- tests/cases/ImportExport/TestOPML.php | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/ImportExport/AbstractImportExport.php b/lib/ImportExport/AbstractImportExport.php index 5445230..3a23443 100644 --- a/lib/ImportExport/AbstractImportExport.php +++ b/lib/ImportExport/AbstractImportExport.php @@ -141,7 +141,7 @@ abstract class AbstractImportExport { return true; } - abstract public function parse(string $data, bool $flat): array; + abstract protected function parse(string $data, bool $flat): array; abstract public function export(string $user, bool $flat = false): string; diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php index 9225269..aa311f8 100644 --- a/lib/ImportExport/OPML.php +++ b/lib/ImportExport/OPML.php @@ -10,7 +10,7 @@ use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\User\Exception as UserException; class OPML extends AbstractImportExport { - public function parse(string $opml, bool $flat): array { + protected function parse(string $opml, bool $flat): array { $d = new \DOMDocument; if (!@$d->loadXML($opml)) { // not a valid XML document diff --git a/tests/cases/ImportExport/TestOPML.php b/tests/cases/ImportExport/TestOPML.php index 0002f2c..e4ef7b4 100644 --- a/tests/cases/ImportExport/TestOPML.php +++ b/tests/cases/ImportExport/TestOPML.php @@ -109,7 +109,8 @@ OPML_EXPORT_SERIALIZATION; /** @dataProvider provideParserData */ public function testParseOpmlForImport(string $file, bool $flat, $exp) { $data = file_get_contents(\JKingWeb\Arsse\DOCROOT."Import/OPML/$file"); - $parser = new OPML; + // set up a partial mock to make the ImportExport::parse() method visible + $parser = \Phake::makeVisible(\Phake::partialMock(OPML::class)); if ($exp instanceof \JKingWeb\Arsse\AbstractException) { $this->assertException($exp); $parser->parse($data, $flat); From 103755cfb400e864b6312f252ac47249e4b6aa3c Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 5 Jul 2019 19:01:34 -0400 Subject: [PATCH 128/142] Test fixture for import tests --- tests/cases/ImportExport/TestImportExport.php | 76 +++++++++++++++++++ tests/lib/AbstractTest.php | 2 +- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/tests/cases/ImportExport/TestImportExport.php b/tests/cases/ImportExport/TestImportExport.php index 9001b11..c1b5e14 100644 --- a/tests/cases/ImportExport/TestImportExport.php +++ b/tests/cases/ImportExport/TestImportExport.php @@ -6,6 +6,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\ImportExport; +use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Db\SQLite3\Driver; use JKingWeb\Arsse\ImportExport\AbstractImportExport; use JKingWeb\Arsse\ImportExport\Exception; @@ -15,9 +16,20 @@ use JKingWeb\Arsse\Test\Database; class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest { protected $drv; protected $proc; + protected $checkTables = [ + 'arsse_folders' => ["id", "owner", "parent", "name"], + 'arsse_feeds' => ['id', 'url'], + 'arsse_subscriptions' => ["id", "owner", "folder", "feed", "title"], + 'arsse_tags' => ["id", "owner", "name"], + 'arsse_tag_members' => ["tag", "subscription", "assigned"], + ]; public function setUp() { self::clearData(); + // create a mock user manager + Arsse::$user = \Phake::mock(\JKingWeb\Arsse\User::class); + \Phake::when(Arsse::$user)->exists->thenReturn(true); + \Phake::when(Arsse::$user)->authorize->thenReturn(true); // create a mock Import/Export processor $this->proc = \Phake::partialMock(AbstractImportExport::class); // initialize an SQLite memeory database @@ -37,6 +49,8 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest { 'password' => 'str', ], 'rows' => [ + ["john.doe@example.com", ""], + ["jane.doe@example.com", ""], ], ], 'arsse_folders' => [ @@ -47,6 +61,12 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest { 'name' => "str", ], 'rows' => [ + [1, "john.doe@example.com", null, "Science"], + [2, "john.doe@example.com", 1, "Rocketry"], + [3, "john.doe@example.com", null, "Politics"], + [4, "john.doe@example.com", null, "Photography"], + [5, "john.doe@example.com", 3, "Local"], + [6, "john.doe@example.com", 3, "National"], ], ], 'arsse_feeds' => [ @@ -56,16 +76,29 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest { 'title' => "str", ], 'rows' => [ + [1, "http://localhost:8000/Import/nasa-jpl", "NASA JPL"], + [2, "http://localhost:8000/Import/torstar", "Toronto Star"], + [3, "http://localhost:8000/Import/ars", "Ars Technica"], + [4, "http://localhost:8000/Import/cbc", "CBC News"], + [5, "http://localhost:8000/Import/citizen", "Ottawa Citizen"], + [6, "http://localhost:8000/Import/eurogamer", "Eurogamer"], ], ], 'arsse_subscriptions' => [ 'columns' => [ 'id' => "int", 'owner' => "str", + 'folder' => "int", 'feed' => "int", 'title' => "str", ], 'rows' => [ + [1, "john.doe@example.com", 2, 1, "NASA JPL"], + [2, "john.doe@example.com", 5, 2, "Toronto Star"], + [3, "john.doe@example.com", 1, 3, "Ars Technica"], + [4, "john.doe@example.com", 6, 4, "CBC News"], + [5, "john.doe@example.com", 6, 5, "Ottawa Citizen"], + [6, "john.doe@example.com", null, 6, "Eurogamer"], ], ], 'arsse_tags' => [ @@ -75,6 +108,12 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest { 'name' => "str", ], 'rows' => [ + [1, "john.doe@example.com", "canada"], + [2, "john.doe@example.com", "frequent"], + [3, "john.doe@example.com", "gaming"], + [4, "john.doe@example.com", "news"], + [5, "john.doe@example.com", "tech"], + [6, "john.doe@example.com", "toronto"], ], ], 'arsse_tag_members' => [ @@ -84,9 +123,22 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest { 'assigned' => "bool", ], 'rows' => [ + [1, 2, 1], + [1, 4, 1], + [1, 5, 1], + [2, 3, 1], + [2, 6, 1], + [3, 6, 1], + [4, 2, 1], + [4, 4, 1], + [4, 5, 1], + [5, 1, 1], + [5, 3, 1], + [6, 2, 1], ], ], ]; + $this->primeDatabase($this->drv, $this->data); } public function tearDown() { @@ -94,4 +146,28 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest { $this->proc = null; self::clearData(); } + + public function testMakeNoEffectiveChnages() { + $in = [[ + ['url' => "http://localhost:8000/Import/nasa-jpl", 'title' => "NASA JPL", 'folder' => 3, 'tags' => ["tech"]], + ['url' => "http://localhost:8000/Import/ars", 'title' => "Ars Technica", 'folder' => 2, 'tags' => ["frequent", "tech"]], + ['url' => "http://localhost:8000/Import/torstar", 'title' => "Toronto Star", 'folder' => 5, 'tags' => ["news", "canada", "toronto"]], + ['url' => "http://localhost:8000/Import/citizen", 'title' => "Ottawa Citizen", 'folder' => 6, 'tags' => ["news", "canada"]], + ['url' => "http://localhost:8000/Import/eurogamer", 'title' => "Eurogamer", 'folder' => 0, 'tags' => ["gaming", "frequent"]], + ['url' => "http://localhost:8000/Import/cbc", 'title' => "CBC News", 'folder' => 6, 'tags' => ["news", "canada"]], + ], [1 => + ['id' => 1, 'name' => "Photography", 'parent' => 0], + ['id' => 2, 'name' => "Science", 'parent' => 0], + ['id' => 3, 'name' => "Rocketry", 'parent' => 2], + ['id' => 4, 'name' => "Politics", 'parent' => 0], + ['id' => 5, 'name' => "Local", 'parent' => 4], + ['id' => 6, 'name' => "National", 'parent' => 4], + ]]; + \Phake::when($this->proc)->parse->thenReturn($in); + $exp = $this->primeExpectations($this->data, $this->checkTables); + $this->proc->import("john.doe@example.com", "", false, false); + $this->compareExpectations($this->drv, $exp); + $this->proc->import("john.doe@example.com", "", false, true); + $this->compareExpectations($this->drv, $exp); + } } diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php index b1b79f1..30b69b0 100644 --- a/tests/lib/AbstractTest.php +++ b/tests/lib/AbstractTest.php @@ -203,7 +203,7 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { return true; } - public function primeExpectations(array $source, array $tableSpecs = null): array { + public function primeExpectations(array $source, array $tableSpecs): array { $out = []; foreach ($tableSpecs as $table => $columns) { // make sure the source has the table we want From 8f9678b8a40c220a30c68e285632f646c97c69d6 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 5 Jul 2019 21:18:30 -0400 Subject: [PATCH 129/142] Tests for baasic import errors --- tests/cases/ImportExport/TestImportExport.php | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/tests/cases/ImportExport/TestImportExport.php b/tests/cases/ImportExport/TestImportExport.php index c1b5e14..40c1cd3 100644 --- a/tests/cases/ImportExport/TestImportExport.php +++ b/tests/cases/ImportExport/TestImportExport.php @@ -147,7 +147,34 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest { self::clearData(); } - public function testMakeNoEffectiveChnages() { + public function testImportForAMissingUser() { + \Phake::when(Arsse::$user)->exists->thenReturn(false); + $this->assertException("doesNotExist", "User"); + $this->proc->import("john.doe@example.com", "", false, false); + } + + public function testImportWithInvalidFolder() { + $in = [[ + ], [1 => + ['id' => 1, 'name' => "", 'parent' => 0], + ]]; + \Phake::when($this->proc)->parse->thenReturn($in); + $this->assertException("invalidFolderName", "ImportExport"); + $this->proc->import("john.doe@example.com", "", false, false); + } + + public function testImportWithDuplicateFolder() { + $in = [[ + ], [1 => + ['id' => 1, 'name' => "New", 'parent' => 0], + ['id' => 2, 'name' => "New", 'parent' => 0], + ]]; + \Phake::when($this->proc)->parse->thenReturn($in); + $this->assertException("invalidFolderCopy", "ImportExport"); + $this->proc->import("john.doe@example.com", "", false, false); + } + + public function testMakeNoEffectiveChanges() { $in = [[ ['url' => "http://localhost:8000/Import/nasa-jpl", 'title' => "NASA JPL", 'folder' => 3, 'tags' => ["tech"]], ['url' => "http://localhost:8000/Import/ars", 'title' => "Ars Technica", 'folder' => 2, 'tags' => ["frequent", "tech"]], From 0480465e7eeb18e9c20f2b8db21b129ad96ab37a Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 24 Jul 2019 09:10:13 -0400 Subject: [PATCH 130/142] Test Fever XML responses Fixes #158 --- lib/REST/Fever/API.php | 8 +++++--- tests/cases/REST/Fever/TestAPI.php | 15 ++++++++++++++- tests/lib/AbstractTest.php | 5 +++-- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 643d1e8..59f7a9f 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -188,7 +188,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $d = $p->ownerDocument; foreach ($data as $k => $v) { if (!is_array($v)) { - $p->appendChild($d->createElement($k, $v)); + $p->appendChild($d->createElement($k, (string) $v)); } elseif (isset($v[0])) { // this is a very simplistic check for an indexed array // it would not pass muster in the face of generic data, @@ -206,9 +206,11 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $d = $p->ownerDocument; foreach ($data as $v) { if (!is_array($v)) { - $p->appendChild($d->createElement($k, $v)); + // this case is never encountered with Fever's output + $p->appendChild($d->createElement($k, (string) $v)); // @codeCoverageIgnore } elseif (isset($v[0])) { - $p->appendChild($this->makeXMLIndexed($v, $d->createElement($k), substr($k, 0, strlen($k) - 1))); + // this case is never encountered with Fever's output + $p->appendChild($this->makeXMLIndexed($v, $d->createElement($k), substr($k, 0, strlen($k) - 1))); // @codeCoverageIgnore } else { $p->appendChild($this->makeXMLAssoc($v, $d->createElement($k))); } diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index 4023122..def389a 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -17,11 +17,14 @@ use JKingWeb\Arsse\REST\Fever\API; use Psr\Http\Message\ResponseInterface; use Zend\Diactoros\ServerRequest; use Zend\Diactoros\Response\JsonResponse; +use Zend\Diactoros\Response\XmlResponse; use Zend\Diactoros\Response\EmptyResponse; -use PHPUnit\Util\Json; /** @covers \JKingWeb\Arsse\REST\Fever\API */ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { + /** @var \JKingWeb\Arsse\REST\Fever\API */ + protected $h; + protected $articles = [ 'db' => [ [ @@ -483,4 +486,14 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { $this->assertMessage($exp, $act); \Phake::verify(Arsse::$db)->articleMark; // only called one time, above } + + public function testOutputToXml() { + \Phake::when($this->h)->processRequest->thenReturn([ + 'items' => $this->articles['rest'], + 'total_items' => 1024, + ]); + $exp = new XmlResponse("1018Article title 1<p>Article content 1</p>http://example.com/1009466848001028Article title 2<p>Article content 2</p>http://example.com/2019467712001039Article title 3<p>Article content 3</p>http://example.com/3109468576001049Article title 4<p>Article content 4</p>http://example.com/41194694400010510Article title 5<p>Article content 5</p>http://example.com/5009470304001024"); + $act = $this->h->dispatch($this->req("api=xml")); + $this->assertMessage($exp, $act); + } } diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php index f55ca9b..cef0a8a 100644 --- a/tests/lib/AbstractTest.php +++ b/tests/lib/AbstractTest.php @@ -9,14 +9,13 @@ namespace JKingWeb\Arsse\Test; use JKingWeb\Arsse\Exception; use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Conf; -use JKingWeb\Arsse\CLI; use JKingWeb\Arsse\Misc\Date; use Psr\Http\Message\MessageInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; use Zend\Diactoros\Response\JsonResponse; -use Zend\Diactoros\Response\EmptyResponse; +use Zend\Diactoros\Response\XmlResponse; /** @coversNothing */ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { @@ -93,6 +92,8 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { if ($exp instanceof JsonResponse) { $this->assertEquals($exp->getPayload(), $act->getPayload(), $text); $this->assertSame($exp->getPayload(), $act->getPayload(), $text); + } elseif ($exp instanceof XmlResponse) { + $this->assertXmlStringEqualsXmlString((string) $exp->getBody(), (string) $act->getBody(), $text); } else { $this->assertEquals((string) $exp->getBody(), (string) $act->getBody(), $text); } From 61b942df70bba21827c9adda1a7abb3be5fa49f4 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 24 Jul 2019 12:27:50 -0400 Subject: [PATCH 131/142] Defer Fever favicons to a future release --- lib/REST/Fever/API.php | 13 +++++++++++-- tests/cases/REST/Fever/TestAPI.php | 10 ++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 59f7a9f..83c3be8 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -26,6 +26,8 @@ use Zend\Diactoros\Response\EmptyResponse; class API extends \JKingWeb\Arsse\REST\AbstractHandler { const LEVEL = 3; + const GENERIC_ICON_TYPE = "image/png;base64"; + const GENERIC_ICON_DATA = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAZdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAuMjHxIGmVAAAADUlEQVQYV2NgYGBgAAAABQABijPjAAAAAABJRU5ErkJggg=="; // GET parameters for which we only check presence: these will be converted to booleans const PARAM_BOOL = ["groups", "feeds", "items", "favicons", "links", "unread_item_ids", "saved_item_ids"]; @@ -143,7 +145,14 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $out['feeds_groups'] = $this->getRelationships(); } if ($G['favicons']) { - # deal with favicons + // TODO: implement favicons properly + // we provide a single blank favicon for now + $out['favicons'] = [ + [ + 'id' => 0, + 'data' => self::GENERIC_ICON_TYPE.",".self::GENERIC_ICON_DATA, + ], + ]; } if ($G['items']) { $out['items'] = $this->getItems($G); @@ -318,7 +327,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { foreach (arsse::$db->subscriptionList(Arsse::$user->id) as $sub) { $out[] = [ 'id' => (int) $sub['id'], - 'favicon_id' => (int) ($sub['favicon'] ? $sub['feed'] : 0), + 'favicon_id' => 0, // TODO: implement favicons 'title' => (string) $sub['title'], 'url' => $sub['url'], 'site_url' => $sub['source'], diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index def389a..b81bdc3 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -307,9 +307,9 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { ])); $exp = new JsonResponse([ 'feeds' => [ - ['id' => 1, 'favicon_id' => 5, 'title' => "Ankh-Morpork News", 'url' => "http://example.com/feed", 'site_url' => "http://example.com/", 'is_spark' => 0, 'last_updated_on_time' => strtotime("2019-01-01T21:12:00Z")], + ['id' => 1, 'favicon_id' => 0, 'title' => "Ankh-Morpork News", 'url' => "http://example.com/feed", 'site_url' => "http://example.com/", 'is_spark' => 0, 'last_updated_on_time' => strtotime("2019-01-01T21:12:00Z")], ['id' => 2, 'favicon_id' => 0, 'title' => "Ook, Ook Eek Ook!", 'url' => "http://example.net/feed", 'site_url' => "http://example.net/", 'is_spark' => 0, 'last_updated_on_time' => strtotime("1988-06-24T12:21:00Z")], - ['id' => 3, 'favicon_id' => 1, 'title' => "The Last Soul", 'url' => "http://example.org/feed", 'site_url' => "http://example.org/", 'is_spark' => 0, 'last_updated_on_time' => strtotime("1991-08-12T03:22:00Z")], + ['id' => 3, 'favicon_id' => 0, 'title' => "The Last Soul", 'url' => "http://example.org/feed", 'site_url' => "http://example.org/", 'is_spark' => 0, 'last_updated_on_time' => strtotime("1991-08-12T03:22:00Z")], ], 'feeds_groups' => [ ['group_id' => 1, 'feed_ids' => "1,2"], @@ -496,4 +496,10 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { $act = $this->h->dispatch($this->req("api=xml")); $this->assertMessage($exp, $act); } + + public function testListFeedIcons() { + $act = $this->h->dispatch($this->req("api&favicons")); + $exp = new JsonResponse(['favicons' => [['id' => 0, 'data' => API::GENERIC_ICON_TYPE.",".API::GENERIC_ICON_DATA]]]); + $this->assertMessage($exp, $act); + } } From 56bb4608205d9b903116ac10d8822f7256272e71 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 24 Jul 2019 12:32:00 -0400 Subject: [PATCH 132/142] Test answering OPTIONS requests in Fever --- tests/cases/REST/Fever/TestAPI.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index b81bdc3..3393dde 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -502,4 +502,13 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { $exp = new JsonResponse(['favicons' => [['id' => 0, 'data' => API::GENERIC_ICON_TYPE.",".API::GENERIC_ICON_DATA]]]); $this->assertMessage($exp, $act); } + + public function testAnswerOptionsRequest() { + $act = $this->h->dispatch($this->req("api", "", "OPTIONS")); + $exp = new EmptyResponse(204, [ + 'Allow' => "POST", + 'Accept' => "application/x-www-form-urlencoded", + ]); + $this->assertMessage($exp, $act); + } } From 0e95892aea234d70187d676fc7df79d6b8cc7612 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 24 Jul 2019 14:20:17 -0400 Subject: [PATCH 133/142] Do not necessarily ignore blank tags in import We still make them practically impossible in OPML imports, however --- lib/AbstractException.php | 1 + lib/ImportExport/AbstractImportExport.php | 4 ++-- lib/ImportExport/OPML.php | 4 ++++ locale/en.php | 1 + tests/cases/ImportExport/TestOPML.php | 2 +- 5 files changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/AbstractException.php b/lib/AbstractException.php index 1e3a2dd..0165d46 100644 --- a/lib/AbstractException.php +++ b/lib/AbstractException.php @@ -94,6 +94,7 @@ abstract class AbstractException extends \Exception { "ImportExport/Exception.invalidSemantics" => 10612, "ImportExport/Exception.invalidFolderName" => 10613, "ImportExport/Exception.invalidFolderCopy" => 10614, + "ImportExport/Exception.invalidTagName" => 10615, ]; public function __construct(string $msgID = "", $vars = null, \Throwable $e = null) { diff --git a/lib/ImportExport/AbstractImportExport.php b/lib/ImportExport/AbstractImportExport.php index 3a23443..66cc9b2 100644 --- a/lib/ImportExport/AbstractImportExport.php +++ b/lib/ImportExport/AbstractImportExport.php @@ -85,8 +85,8 @@ abstract class AbstractImportExport { // compile the set of used tags, if this is a new feed or we're doing a full replacement foreach ($f['tags'] as $t) { if (!strlen(trim($t))) { - // ignore any blank tags - continue; + // fail if we have any blank tags + throw new Exception("invalidTagName"); } if (!isset($tagMap[$t])) { // populate the tag map diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php index aa311f8..30a3cc5 100644 --- a/lib/ImportExport/OPML.php +++ b/lib/ImportExport/OPML.php @@ -61,6 +61,10 @@ class OPML extends AbstractImportExport { $categories = array_map(function($v) { return trim(preg_replace("/\s+/", " ", $v)); }, explode(",", $categories)); + // filter out any blank categories + $categories = array_filter($categories, function($v) { + return strlen($v); + }); } else { $categories = []; } diff --git a/locale/en.php b/locale/en.php index 19fc724..e095db8 100644 --- a/locale/en.php +++ b/locale/en.php @@ -163,4 +163,5 @@ return [ 'Exception.JKingWeb/Arsse/ImportExport/Exception.invalidSemantics' => 'Input data is not valid {type} data', 'Exception.JKingWeb/Arsse/ImportExport/Exception.invalidFolderName' => 'Input data contains an invalid folder name', 'Exception.JKingWeb/Arsse/ImportExport/Exception.invalidFolderCopy' => 'Input data contains multiple folders of the same name under the same parent', + 'Exception.JKingWeb/Arsse/ImportExport/Exception.invalidTagName' => 'Input data contains an invalid tag name', ]; diff --git a/tests/cases/ImportExport/TestOPML.php b/tests/cases/ImportExport/TestOPML.php index e4ef7b4..503211c 100644 --- a/tests/cases/ImportExport/TestOPML.php +++ b/tests/cases/ImportExport/TestOPML.php @@ -135,7 +135,7 @@ OPML_EXPORT_SERIALIZATION; ['url' => "http://example.com/3", 'title' => "", 'folder' => 0, 'tags' => []], ['url' => "http://example.com/4", 'title' => "", 'folder' => 0, 'tags' => []], ['url' => "", 'title' => "", 'folder' => 0, 'tags' => ["whee"]], - ['url' => "", 'title' => "", 'folder' => 0, 'tags' => ["whee", "whoo", ""]], + ['url' => "", 'title' => "", 'folder' => 0, 'tags' => ["whee", "whoo"]], ], []]], ["FoldersOnly.opml", true, [[], []]], ["FoldersOnly.opml", false, [[], [1 => From 13b76dea0cda274872e57e85168499c7a375036c Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 25 Jul 2019 13:14:29 -0400 Subject: [PATCH 134/142] Tests for generic importing --- tests/cases/ImportExport/TestImportExport.php | 66 ++++++++++++++++++- tests/docroot/Import/some-feed.php | 18 +++++ tests/lib/AbstractTest.php | 6 +- 3 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 tests/docroot/Import/some-feed.php diff --git a/tests/cases/ImportExport/TestImportExport.php b/tests/cases/ImportExport/TestImportExport.php index 40c1cd3..f5452a5 100644 --- a/tests/cases/ImportExport/TestImportExport.php +++ b/tests/cases/ImportExport/TestImportExport.php @@ -18,7 +18,7 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest { protected $proc; protected $checkTables = [ 'arsse_folders' => ["id", "owner", "parent", "name"], - 'arsse_feeds' => ['id', 'url'], + 'arsse_feeds' => ["id", "url", "title"], 'arsse_subscriptions' => ["id", "owner", "folder", "feed", "title"], 'arsse_tags' => ["id", "owner", "name"], 'arsse_tag_members' => ["tag", "subscription", "assigned"], @@ -197,4 +197,68 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest { $this->proc->import("john.doe@example.com", "", false, true); $this->compareExpectations($this->drv, $exp); } + + public function testModifyASubscription() { + $in = [[ + ['url' => "http://localhost:8000/Import/nasa-jpl", 'title' => "NASA JPL", 'folder' => 3, 'tags' => ["tech"]], + ['url' => "http://localhost:8000/Import/ars", 'title' => "Ars Technica", 'folder' => 2, 'tags' => ["frequent", "tech"]], + ['url' => "http://localhost:8000/Import/torstar", 'title' => "Toronto Star", 'folder' => 5, 'tags' => ["news", "canada", "toronto"]], + ['url' => "http://localhost:8000/Import/citizen", 'title' => "Ottawa Citizen", 'folder' => 6, 'tags' => ["news", "canada"]], + ['url' => "http://localhost:8000/Import/eurogamer", 'title' => "Eurogamer", 'folder' => 0, 'tags' => ["gaming", "frequent"]], + ['url' => "http://localhost:8000/Import/cbc", 'title' => "CBC", 'folder' => 0, 'tags' => ["news", "canada"]], // moved to root and renamed + ], [1 => + ['id' => 1, 'name' => "Photography", 'parent' => 0], + ['id' => 2, 'name' => "Science", 'parent' => 0], + ['id' => 3, 'name' => "Rocketry", 'parent' => 2], + ['id' => 4, 'name' => "Politics", 'parent' => 0], + ['id' => 5, 'name' => "Local", 'parent' => 4], + ['id' => 6, 'name' => "National", 'parent' => 4], + ]]; + \Phake::when($this->proc)->parse->thenReturn($in); + $this->proc->import("john.doe@example.com", "", false, true); + $exp = $this->primeExpectations($this->data, $this->checkTables); + $exp['arsse_subscriptions']['rows'][3] = [4, "john.doe@example.com", null, 4, "CBC"]; + $this->compareExpectations($this->drv, $exp); + } + + public function testImportAFeed() { + $in = [[ + ['url' => "http://localhost:8000/Import/some-feed", 'title' => "Some Feed", 'folder' => 0, 'tags' => ["frequent", "cryptic"]], //one existing tag and one new one + ], []]; + \Phake::when($this->proc)->parse->thenReturn($in); + $this->proc->import("john.doe@example.com", "", false, false); + $exp = $this->primeExpectations($this->data, $this->checkTables); + $exp['arsse_feeds']['rows'][] = [7, "http://localhost:8000/Import/some-feed", "Some feed"]; // author-supplied and user-supplied titles differ + $exp['arsse_subscriptions']['rows'][] = [7, "john.doe@example.com", null, 7, "Some Feed"]; + $exp['arsse_tags']['rows'][] = [7, "john.doe@example.com", "cryptic"]; + $exp['arsse_tag_members']['rows'][] = [2, 7, 1]; + $exp['arsse_tag_members']['rows'][] = [7, 7, 1]; + $this->compareExpectations($this->drv, $exp); + } + + public function testImportAFeedWithAnInvalidTag() { + $in = [[ + ['url' => "http://localhost:8000/Import/some-feed", 'title' => "Some Feed", 'folder' => 0, 'tags' => [""]], + ], []]; + \Phake::when($this->proc)->parse->thenReturn($in); + $this->assertException("invalidTagName", "ImportExport"); + $this->proc->import("john.doe@example.com", "", false, false); + } + + public function testReplaceData() { + $in = [[ + ['url' => "http://localhost:8000/Import/some-feed", 'title' => "Some Feed", 'folder' => 1, 'tags' => ["frequent", "cryptic"]], + ], [1 => + ['id' => 1, 'name' => "Photography", 'parent' => 0], + ]]; + \Phake::when($this->proc)->parse->thenReturn($in); + $this->proc->import("john.doe@example.com", "", false, true); + $exp = $this->primeExpectations($this->data, $this->checkTables); + $exp['arsse_feeds']['rows'][] = [7, "http://localhost:8000/Import/some-feed", "Some feed"]; // author-supplied and user-supplied titles differ + $exp['arsse_subscriptions']['rows'] = [[7, "john.doe@example.com", 4, 7, "Some Feed"]]; + $exp['arsse_tags']['rows'] = [[2, "john.doe@example.com", "frequent"], [7, "john.doe@example.com", "cryptic"]]; + $exp['arsse_tag_members']['rows'] = [[2, 7, 1], [7, 7, 1]]; + $exp['arsse_folders']['rows'] = [[4, "john.doe@example.com", null, "Photography"]]; + $this->compareExpectations($this->drv, $exp); + } } diff --git a/tests/docroot/Import/some-feed.php b/tests/docroot/Import/some-feed.php new file mode 100644 index 0000000..eec5856 --- /dev/null +++ b/tests/docroot/Import/some-feed.php @@ -0,0 +1,18 @@ + "application/rss+xml", + 'content' => << + + Some feed + http://example.com/ + Just a generic feed + + + http://localhost:8000/Import/some-feed/some-article + Some article + This feed is used only to demonstrate failure modes external to the feed itself + + + +MESSAGE_BODY +]; diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php index 1644b59..6334e5c 100644 --- a/tests/lib/AbstractTest.php +++ b/tests/lib/AbstractTest.php @@ -173,7 +173,7 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { $data = $drv->prepare("SELECT $cols from $table")->run()->getAll(); $cols = array_keys($info['columns']); foreach ($info['rows'] as $index => $row) { - $this->assertCount(sizeof($cols), $row, "The number of values for array index $index does not match the number of fields"); + $this->assertCount(sizeof($cols), $row, "The number of columns in array index $index of expectations for table $table does not match its definition"); $row = array_combine($cols, $row); foreach ($data as $index => $test) { foreach ($test as $col => $value) { @@ -197,11 +197,11 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { break; } } - $this->assertContains($row, $data, "Table $table does not contain record at array index $index."); + $this->assertContains($row, $data, "Actual Table $table does not contain record at expected array index $index"); $found = array_search($row, $data, true); unset($data[$found]); } - $this->assertSame([], $data); + $this->assertSame([], $data, "Actual table $table contains extra rows not in expectations"); } return true; } From faf524c54fc76db9273dd0d280a34e96078a6736 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 25 Jul 2019 15:45:18 -0400 Subject: [PATCH 135/142] CLI test for import Fixes #35 --- lib/CLI.php | 7 +-- lib/ImportExport/AbstractImportExport.php | 2 +- tests/cases/CLI/TestCLI.php | 57 +++++++++++++++++++++++ 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/lib/CLI.php b/lib/CLI.php index 0b1f3b9..7c0d30b 100644 --- a/lib/CLI.php +++ b/lib/CLI.php @@ -196,11 +196,11 @@ USAGE_TEXT; case "export": $u = $args['']; $file = $this->resolveFile($args[''], "w"); - return (int) !$this->getInstance(OPML::class)->exportFile($file, $u, $args['--flat']); + return (int) !$this->getInstance(OPML::class)->exportFile($file, $u, ($args['--flat'] || $args['-f'])); case "import": $u = $args['']; - $file = $this->resolveFile($args[''], "w"); - return (int) !$this->getInstance(OPML::class)->importFile($file, $u, $args['--flat'], $args['--replace']); + $file = $this->resolveFile($args[''], "r"); + return (int) !$this->getInstance(OPML::class)->importFile($file, $u, ($args['--flat'] || $args['-f']), ($args['--replace'] || $args['-r'])); } } catch (AbstractException $e) { $this->logError($e->getMessage()); @@ -213,6 +213,7 @@ USAGE_TEXT; fwrite(STDERR, $msg.\PHP_EOL); } + /** @codeCoverageIgnore */ protected function getInstance(string $class) { return new $class; } diff --git a/lib/ImportExport/AbstractImportExport.php b/lib/ImportExport/AbstractImportExport.php index 66cc9b2..f882ea1 100644 --- a/lib/ImportExport/AbstractImportExport.php +++ b/lib/ImportExport/AbstractImportExport.php @@ -155,7 +155,7 @@ abstract class AbstractImportExport { return true; } - public function importFile(string $file, string $user, bool $flat = false, bool $replace): bool { + public function importFile(string $file, string $user, bool $flat = false, bool $replace = false): bool { $data = @file_get_contents($file); if ($data === false) { // if it fails throw an exception diff --git a/tests/cases/CLI/TestCLI.php b/tests/cases/CLI/TestCLI.php index 46290fc..825bc38 100644 --- a/tests/cases/CLI/TestCLI.php +++ b/tests/cases/CLI/TestCLI.php @@ -311,6 +311,63 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { ["arsse.php export jane.doe@example.com - --flat", 0, "php://output", "jane.doe@example.com", true], ["arsse.php export --flat jane.doe@example.com good.opml", 0, "good.opml", "jane.doe@example.com", true], ["arsse.php export jane.doe@example.com bad.opml --flat", 10604, "bad.opml", "jane.doe@example.com", true], + ["arsse.php export john.doe@example.com -f", 0, "php://output", "john.doe@example.com", true], + ["arsse.php export john.doe@example.com - -f", 0, "php://output", "john.doe@example.com", true], + ["arsse.php export -f john.doe@example.com good.opml", 0, "good.opml", "john.doe@example.com", true], + ["arsse.php export john.doe@example.com bad.opml -f", 10604, "bad.opml", "john.doe@example.com", true], + ["arsse.php export jane.doe@example.com -f", 0, "php://output", "jane.doe@example.com", true], + ["arsse.php export jane.doe@example.com - -f", 0, "php://output", "jane.doe@example.com", true], + ["arsse.php export -f jane.doe@example.com good.opml", 0, "good.opml", "jane.doe@example.com", true], + ["arsse.php export jane.doe@example.com bad.opml -f", 10604, "bad.opml", "jane.doe@example.com", true], + ]; + } + + /** @dataProvider provideOpmlImports */ + public function testImportFromOpml(string $cmd, int $exitStatus, string $file, string $user, bool $flat, bool $replace) { + $opml = Phake::mock(OPML::class); + Phake::when($opml)->importFile("php://input", $user, $flat, $replace)->thenReturn(true); + Phake::when($opml)->importFile("good.opml", $user, $flat, $replace)->thenReturn(true); + Phake::when($opml)->importFile("bad.opml", $user, $flat, $replace)->thenThrow(new \JKingWeb\Arsse\ImportExport\Exception("fileUnreadable")); + Phake::when($this->cli)->getInstance(OPML::class)->thenReturn($opml); + $this->assertConsole($this->cli, $cmd, $exitStatus); + $this->assertLoaded(true); + Phake::verify($opml)->importFile($file, $user, $flat, $replace); + } + + public function provideOpmlImports() { + return [ + ["arsse.php import john.doe@example.com", 0, "php://input", "john.doe@example.com", false, false], + ["arsse.php import john.doe@example.com -", 0, "php://input", "john.doe@example.com", false, false], + ["arsse.php import john.doe@example.com good.opml", 0, "good.opml", "john.doe@example.com", false, false], + ["arsse.php import john.doe@example.com bad.opml", 10603, "bad.opml", "john.doe@example.com", false, false], + ["arsse.php import john.doe@example.com --flat", 0, "php://input", "john.doe@example.com", true, false], + ["arsse.php import john.doe@example.com - --flat", 0, "php://input", "john.doe@example.com", true, false], + ["arsse.php import --flat john.doe@example.com good.opml", 0, "good.opml", "john.doe@example.com", true, false], + ["arsse.php import john.doe@example.com bad.opml --flat", 10603, "bad.opml", "john.doe@example.com", true, false], + ["arsse.php import jane.doe@example.com", 0, "php://input", "jane.doe@example.com", false, false], + ["arsse.php import jane.doe@example.com -", 0, "php://input", "jane.doe@example.com", false, false], + ["arsse.php import jane.doe@example.com good.opml", 0, "good.opml", "jane.doe@example.com", false, false], + ["arsse.php import jane.doe@example.com bad.opml", 10603, "bad.opml", "jane.doe@example.com", false, false], + ["arsse.php import jane.doe@example.com --flat", 0, "php://input", "jane.doe@example.com", true, false], + ["arsse.php import jane.doe@example.com - --flat", 0, "php://input", "jane.doe@example.com", true, false], + ["arsse.php import --flat jane.doe@example.com good.opml", 0, "good.opml", "jane.doe@example.com", true, false], + ["arsse.php import jane.doe@example.com bad.opml --flat", 10603, "bad.opml", "jane.doe@example.com", true, false], + ["arsse.php import john.doe@example.com --replace", 0, "php://input", "john.doe@example.com", false, true], + ["arsse.php import john.doe@example.com - -r", 0, "php://input", "john.doe@example.com", false, true], + ["arsse.php import --replace john.doe@example.com good.opml", 0, "good.opml", "john.doe@example.com", false, true], + ["arsse.php import -r john.doe@example.com bad.opml", 10603, "bad.opml", "john.doe@example.com", false, true], + ["arsse.php import --replace john.doe@example.com --flat", 0, "php://input", "john.doe@example.com", true, true], + ["arsse.php import -r john.doe@example.com - --flat", 0, "php://input", "john.doe@example.com", true, true], + ["arsse.php import --flat john.doe@example.com good.opml -r", 0, "good.opml", "john.doe@example.com", true, true], + ["arsse.php import --replace john.doe@example.com bad.opml --flat", 10603, "bad.opml", "john.doe@example.com", true, true], + ["arsse.php import jane.doe@example.com -r ", 0, "php://input", "jane.doe@example.com", false, true], + ["arsse.php import jane.doe@example.com - --replace", 0, "php://input", "jane.doe@example.com", false, true], + ["arsse.php import -r jane.doe@example.com good.opml", 0, "good.opml", "jane.doe@example.com", false, true], + ["arsse.php import --replace jane.doe@example.com bad.opml", 10603, "bad.opml", "jane.doe@example.com", false, true], + ["arsse.php import jane.doe@example.com --flat -r", 0, "php://input", "jane.doe@example.com", true, true], + ["arsse.php import jane.doe@example.com - --flat --replace", 0, "php://input", "jane.doe@example.com", true, true], + ["arsse.php import --flat jane.doe@example.com good.opml -r", 0, "good.opml", "jane.doe@example.com", true, true], + ["arsse.php import jane.doe@example.com bad.opml --replace --flat", 10603, "bad.opml", "jane.doe@example.com", true, true], ]; } } From be92d2f05278a9c0448934b4e58c6271ab88cf70 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 25 Jul 2019 19:23:35 -0400 Subject: [PATCH 136/142] Documentation update; fixes #168 --- CHANGELOG | 1 + README.md | 34 +++++++++++++++++++++++++++++++--- UPGRADING | 7 ++++++- dist/nginx.conf | 5 +++++ 4 files changed, 43 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index b0b42f4..961082f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -6,6 +6,7 @@ New features: - Command line functionality for clearing a password, disabling the account - Command line options for dealing with Fever passwords - Command line functionality for exporting subscriptions to OPML +- Command line functionality for cron-based feed updating - Command line documentation of all commands and options Bug fixes: diff --git a/README.md b/README.md index f568a4a..fcabd69 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,14 @@ # The Advanced RSS Environment -The Arsse is a news aggregator server which implements multiple synchronization protocols, including [version 1.2][NCNv1] of [NextCloud News][NCN]' protocol and the [Tiny Tiny RSS][TTRSS] protocol (details below). Unlike most other aggregator servers, The Arsse does not include a Web front-end (though one is planned as a separate project), and it relies on existing protocols to maximize compatibility with existing clients. +The Arsse is a news aggregator server which implements multiple synchronization protocols. Unlike most other aggregator servers, The Arsse does not include a Web front-end (though one is planned as a separate project), and it relies on existing protocols to maximize compatibility with existing clients. Supported protocols are: -At present the software should be considered in an "alpha" state: though its core subsystems are covered by unit tests and should be free of major bugs, not everything has been rigorously tested. Additionally, many features one would expect from other similar software have yet to be implemented. Areas of future work include: +- [NextCloud News][NCNv1] +- [Tiny Tiny RSS][TTRSS] +- [Fever][Fever] -- Providing more sync protocols (Google Reader, Fever, others) +At present the software should be considered in an "alpha" state: many features one would expect from other similar software have yet to be implemented. Areas of future work include: + +- Providing more sync protocols (Google Reader, others) - Better packaging and configuration samples - A user manual @@ -48,6 +52,8 @@ The Arsse includes a `user add []` console command to add u Alternatively, if the Web server is configured to handle authentication, you may set the configuration option `userPreAuth` to `true` and The Arsse will defer to the Web server and automatically add any missing users as it encounters them. +Console commands are also available to import from and export to OPML files. Consult `php arsse.php --help` for full details. + ## Installation from source If installing from the Git repository rather than a download package, you will need to follow extra steps before the instructions in the section above. @@ -194,6 +200,27 @@ Tiny Tiny RSS itself is unaware of HTTP authentication: if HTTP authentication i In all cases, supplying invalid HTTP credentials will result in a 401 response. +### Fever + +Unlike other protocols thus far supported by The Arsse, a reference implementation of [the Fever protocol][Fever] is no longer available: Fever was witdrawn from sale in 2016. Consequently the Arsse's implementation may not replicate all of Fever's functionality correctly. Moreover, some features have been deliberately omitted. + +#### Special considerations + +- Because of Fever's insecure authentication protocol, a Fever-specific password must be created before a user can communicate via the Fever protocol. Consult The Arsse's online help (`php arsse.php --help`) for instructions on how to set the necessary password +- The Fever protocol does not allow for adding or modifying feeds. Another protocol or OPML importing must be used to manage feeds +- Unlike other protocols supported by The Arsse, Fever uses "groups" (more commonly known as tags or labels) instead of folders to organize feeds. Currently OPML importing is the only means of managing groups + +#### Missing features + +- All feeds are considered "Kindling" +- The "Hot Links" feature is not implemented; when requested, an empty array will be returned. As there is no way to classify a feed as a "Spark" in the protocol itself and no documentation exists on how link temperature was calculated, an implementation is unlikely to appear in the future +- Favicons are not currently supported; all feeds have a simple blank image as their favicon + +#### Other notes + +- The undocumented `group_ids`, `feed_ids`, and `as=unread` parameters are all supported +- XML output is supported, but may not behave as Fever did. JSON output is highly recommended + [newIssue]: https://code.mensbeam.com/MensBeam/arsse/issues/new [Composer]: https://getcomposer.org/ [picoFeed]: https://github.com/miniflux/picoFeed/ @@ -205,3 +232,4 @@ In all cases, supplying invalid HTTP credentials will result in a 401 response. [News+]: https://github.com/noinnion/newsplus/ [ext-feedreader]: https://github.com/jangernert/FeedReader/tree/master/data/tt-rss-feedreader-plugin [ext-newsplus]: https://github.com/hrk/tt-rss-newsplus-plugin +[Fever]: https://web.archive.org/web/20161217042229/https://feedafever.com/api diff --git a/UPGRADING b/UPGRADING index ea3c84a..c96f41c 100644 --- a/UPGRADING +++ b/UPGRADING @@ -15,7 +15,12 @@ Upgrading from 0.7.1 to 0.8.0 - The database schema has changed from rev4 to rev5; if upgrading the database manually, apply the 4.sql file - +- Web server configuration has changed to accommodate Fever; the following URL + paths are affected: + - /fever/ +- The following Composer dependencies have been added: + - zendframework/zend-diactoros (version 2.x) + - zendframework/zend-httphandlerrunner Upgrading from 0.5.1 to 0.6.0 ============================= diff --git a/dist/nginx.conf b/dist/nginx.conf index c7dce50..98e130c 100644 --- a/dist/nginx.conf +++ b/dist/nginx.conf @@ -48,4 +48,9 @@ server { root /usr/share/arsse/www; try_files $uri =404; } + + # Fever protocol + location /fever/ { + try_files $uri @arsse_no_auth; + } } From 422eaf9605505276391db4d8e3da268d7b57aa2b Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 25 Jul 2019 22:34:58 -0400 Subject: [PATCH 137/142] Invalidate sessions on password change; closes #170 --- CHANGELOG | 3 ++- lib/Database.php | 14 ++++++++++---- lib/User.php | 4 ++++ tests/cases/Database/SeriesSession.php | 10 ++++++++++ 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 961082f..caf1e61 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -5,13 +5,14 @@ New features: - Support for the Fever protocol (see README.md for details) - Command line functionality for clearing a password, disabling the account - Command line options for dealing with Fever passwords -- Command line functionality for exporting subscriptions to OPML +- Command line functionality for importing and exporting OPML - Command line functionality for cron-based feed updating - Command line documentation of all commands and options Bug fixes: - Treat command line option -h the same as --help - Sort Tiny Tiny RSS special feeds according to special ordering +- Invalidate sessions when passwords are changed Version 0.7.1 (2019-03-25) ========================== diff --git a/lib/Database.php b/lib/Database.php index daca234..10e66eb 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -340,15 +340,21 @@ class Database { * This function can be used to explicitly invalidate a session after a user logs out * * @param string $user The user who owns the session to be destroyed - * @param string $id The identifier of the session to destroy + * @param string|null $id The identifier of the session to destroy */ - public function sessionDestroy(string $user, string $id): bool { + public function sessionDestroy(string $user, string $id = null): bool { // If the user isn't authorized to perform this action then throw an exception. if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } - // delete the session and report success. - return (bool) $this->db->prepare("DELETE FROM arsse_sessions where id = ? and \"user\" = ?", "str", "str")->run($id, $user)->changes(); + if (is_null($id)) { + // delete all sessions and report success unconditionally if no identifier was specified + $this->db->prepare("DELETE FROM arsse_sessions where \"user\" = ?", "str")->run($user); + return true; + } else { + // otherwise delete only the specified session and report success. + return (bool) $this->db->prepare("DELETE FROM arsse_sessions where id = ? and \"user\" = ?", "str", "str")->run($id, $user)->changes(); + } } /** Resumes a session, returning available session data diff --git a/lib/User.php b/lib/User.php index 4f52980..691d6fa 100644 --- a/lib/User.php +++ b/lib/User.php @@ -110,6 +110,8 @@ class User { if (Arsse::$db->userExists($user)) { // if the password change was successful and the user exists, set the internal password to the same value Arsse::$db->userPasswordSet($user, $out); + // also invalidate any current sessions for the user + Arsse::$db->sessionDestroy($user); } return $out; } @@ -123,6 +125,8 @@ class User { if (Arsse::$db->userExists($user)) { // if the password change was successful and the user exists, set the internal password to the same value Arsse::$db->userPasswordSet($user, null); + // also invalidate any current sessions for the user + Arsse::$db->sessionDestroy($user); } return $out; } diff --git a/tests/cases/Database/SeriesSession.php b/tests/cases/Database/SeriesSession.php index ad9a45b..9e8a388 100644 --- a/tests/cases/Database/SeriesSession.php +++ b/tests/cases/Database/SeriesSession.php @@ -116,6 +116,16 @@ trait SeriesSession { $this->assertFalse(Arsse::$db->sessionDestroy($user, $id)); } + public function testDestroyAllSessions() { + $user = "jane.doe@example.com"; + $this->assertTrue(Arsse::$db->sessionDestroy($user)); + $state = $this->primeExpectations($this->data, ['arsse_sessions' => ["id", "created", "expires", "user"]]); + unset($state['arsse_sessions']['rows'][0]); + unset($state['arsse_sessions']['rows'][1]); + unset($state['arsse_sessions']['rows'][2]); + $this->compareExpectations(static::$drv, $state); + } + public function testDestroyASessionForTheWrongUser() { $user = "john.doe@example.com"; $id = "80fa94c1a11f11e78667001e673b2560"; From cef31907d3d0de1f6dd1dec2bea300c0286bd2c1 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 25 Jul 2019 22:39:54 -0400 Subject: [PATCH 138/142] Cron functionality is not new --- CHANGELOG | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index caf1e61..8e4988d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -6,7 +6,6 @@ New features: - Command line functionality for clearing a password, disabling the account - Command line options for dealing with Fever passwords - Command line functionality for importing and exporting OPML -- Command line functionality for cron-based feed updating - Command line documentation of all commands and options Bug fixes: From f7240301e4ff687a15e815ec6ba39d1e944faeea Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 26 Jul 2019 09:37:51 -0400 Subject: [PATCH 139/142] Basic database maintenance Closes #169 --- CHANGELOG | 3 +++ lib/Database.php | 10 ++++++++-- lib/Db/Driver.php | 6 ++++++ lib/Db/MySQL/Driver.php | 13 +++++++++++++ lib/Db/PostgreSQL/Driver.php | 6 ++++++ lib/Db/SQLite3/Driver.php | 6 ++++++ lib/Service.php | 9 +++++++-- tests/cases/Database/SeriesMiscellany.php | 4 ++++ tests/cases/Db/BaseDriver.php | 5 +++++ tests/cases/Db/BaseUpdate.php | 5 +++++ 10 files changed, 63 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 8e4988d..4cdc00b 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -13,6 +13,9 @@ Bug fixes: - Sort Tiny Tiny RSS special feeds according to special ordering - Invalidate sessions when passwords are changed +Changes: +- Perform regular database maintenance to improve long-term performance + Version 0.7.1 (2019-03-25) ========================== diff --git a/lib/Database.php b/lib/Database.php index 10e66eb..366d84d 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -110,6 +110,11 @@ class Database { return $this->db->charsetAcceptable(); } + /** Performs maintenance on the database to ensure good performance */ + public function driverMaintenance(): bool { + return $this->db->maintenance(); + } + /** Computes the column and value text of an SQL "SET" clause, validating arbitrary input against a whitelist * * Returns an indexed array containing the clause text, an array of types, and another array of values @@ -1788,10 +1793,11 @@ class Database { $limitUnread = Date::sub(Arsse::$conf->purgeArticlesUnread); } $feeds = $this->db->query("SELECT id, size from arsse_feeds")->getAll(); + $deleted = 0; foreach ($feeds as $feed) { - $query->run($feed['id'], $feed['size'], $feed['id'], $limitUnread, $limitRead); + $deleted += $query->run($feed['id'], $feed['size'], $feed['id'], $limitUnread, $limitRead)->changes(); } - return true; + return (bool) $deleted; } /** Ensures the specified article exists and raises an exception otherwise diff --git a/lib/Db/Driver.php b/lib/Db/Driver.php index 7f04dc6..a456fba 100644 --- a/lib/Db/Driver.php +++ b/lib/Db/Driver.php @@ -82,4 +82,10 @@ interface Driver { * This functionality should be avoided in favour of using statement parameters whenever possible */ public function literalString(string $str): string; + + /** Performs implementation-specific database maintenance to ensure good performance + * + * This should be restricted to quick maintenance; in SQLite terms it might include ANALYZE, but not VACUUM + */ + public function maintenance(): bool; } diff --git a/lib/Db/MySQL/Driver.php b/lib/Db/MySQL/Driver.php index cec575b..bb9cac8 100644 --- a/lib/Db/MySQL/Driver.php +++ b/lib/Db/MySQL/Driver.php @@ -216,4 +216,17 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { public function literalString(string $str): string { return "'".$this->db->real_escape_string($str)."'"; } + + public function maintenance(): bool { + // with MySQL each table must be analyzed separately, so we first have to get a list of tables + foreach ($this->query("SHOW TABLES like 'arsse\\_%'") as $table) { + $table = array_pop($table); + if (!preg_match("/^arsse_[a-z_]+$/", $table)) { + // table is not one of ours + continue; // @codeCoverageIgnore + } + $this->query("ANALYZE TABLE $table"); + } + return true; + } } diff --git a/lib/Db/PostgreSQL/Driver.php b/lib/Db/PostgreSQL/Driver.php index 12ad8fc..7550393 100644 --- a/lib/Db/PostgreSQL/Driver.php +++ b/lib/Db/PostgreSQL/Driver.php @@ -225,4 +225,10 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { public function literalString(string $str): string { return pg_escape_literal($this->db, $str); } + + public function maintenance(): bool { + // analyze the database + $this->exec("ANALYZE"); + return true; + } } diff --git a/lib/Db/SQLite3/Driver.php b/lib/Db/SQLite3/Driver.php index 96e345f..6cf290f 100644 --- a/lib/Db/SQLite3/Driver.php +++ b/lib/Db/SQLite3/Driver.php @@ -188,4 +188,10 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { public function literalString(string $str): string { return "'".\SQLite3::escapeString($str)."'"; } + + public function maintenance(): bool { + // analyze the database then checkpoint and truncate the write-ahead log + $this->exec("ANALYZE; PRAGMA wal_checkpoint(truncate)"); + return true; + } } diff --git a/lib/Service.php b/lib/Service.php index bc752ae..93d4e9b 100644 --- a/lib/Service.php +++ b/lib/Service.php @@ -92,7 +92,12 @@ class Service { } public static function cleanupPost(): bool { - // delete old articles, according to configured threasholds - return Arsse::$db->articleCleanup(); + // delete old articles, according to configured thresholds + $deleted = Arsse::$db->articleCleanup(); + // if any articles were deleted, perform database maintenance + if ($deleted) { + Arsse::$db->driverMaintenance(); + } + return true; } } diff --git a/tests/cases/Database/SeriesMiscellany.php b/tests/cases/Database/SeriesMiscellany.php index 0080356..a7591bb 100644 --- a/tests/cases/Database/SeriesMiscellany.php +++ b/tests/cases/Database/SeriesMiscellany.php @@ -44,4 +44,8 @@ trait SeriesMiscellany { public function testCheckCharacterSetAcceptability() { $this->assertInternalType("bool", Arsse::$db->driverCharsetAcceptable()); } + + public function testPerformMaintenance() { + $this->assertTrue(Arsse::$db->driverMaintenance()); + } } diff --git a/tests/cases/Db/BaseDriver.php b/tests/cases/Db/BaseDriver.php index 74ef7c9..677339b 100644 --- a/tests/cases/Db/BaseDriver.php +++ b/tests/cases/Db/BaseDriver.php @@ -382,4 +382,9 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest { public function testProduceAStringLiteral() { $this->assertSame("'It''s a string!'", $this->drv->literalString("It's a string!")); } + + public function testPerformMaintenance() { + // this performs maintenance in the absence of tables; see BaseUpdate.php for another test with tables + $this->assertTrue($this->drv->maintenance()); + } } diff --git a/tests/cases/Db/BaseUpdate.php b/tests/cases/Db/BaseUpdate.php index 2780684..e9bc10d 100644 --- a/tests/cases/Db/BaseUpdate.php +++ b/tests/cases/Db/BaseUpdate.php @@ -130,4 +130,9 @@ class BaseUpdate extends \JKingWeb\Arsse\Test\AbstractTest { $this->assertException("updateTooNew", "Db"); $this->drv->schemaUpdate(-1, $this->base); } + + public function testPerformMaintenance() { + $this->drv->schemaUpdate(Database::SCHEMA_VERSION); + $this->assertTrue($this->drv->maintenance()); + } } From 4282ba1c264ddbe108c416e0016991e3b6733193 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 26 Jul 2019 09:39:46 -0400 Subject: [PATCH 140/142] Version bump --- lib/Arsse.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Arsse.php b/lib/Arsse.php index 6de2425..82c4332 100644 --- a/lib/Arsse.php +++ b/lib/Arsse.php @@ -7,7 +7,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse; class Arsse { - const VERSION = "0.7.1"; + const VERSION = "0.8.0"; /** @var Lang */ public static $lang; From 9f7e1c915cb92537cfd1803e2b62043dd9c4cf9a Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 26 Jul 2019 09:42:36 -0400 Subject: [PATCH 141/142] Start after PostgreSQL and MySQL when relevant --- CHANGELOG | 1 + dist/arsse.service | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 4cdc00b..de0cc2d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -12,6 +12,7 @@ Bug fixes: - Treat command line option -h the same as --help - Sort Tiny Tiny RSS special feeds according to special ordering - Invalidate sessions when passwords are changed +- Correct example systemd unit to start after PostgreSQL and MySQL Changes: - Perform regular database maintenance to improve long-term performance diff --git a/dist/arsse.service b/dist/arsse.service index 3e19ee8..0adcdae 100644 --- a/dist/arsse.service +++ b/dist/arsse.service @@ -1,6 +1,6 @@ [Unit] Description=The Arsse feed fetching service -After=network.target +After=network.target mysql.service postgresql.service [Service] User=www-data @@ -12,4 +12,4 @@ StandardError=syslog ExecStart=/usr/bin/env php /usr/share/arsse/arsse.php daemon [Install] -WantedBy=multi-user.target \ No newline at end of file +WantedBy=multi-user.target From 77b719660b9b510b13822ae4cba0c44f094f037b Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 26 Jul 2019 09:43:45 -0400 Subject: [PATCH 142/142] Date 0.8.0 release --- CHANGELOG | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index de0cc2d..8e2451e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,4 +1,4 @@ -Version 0.8.0 (2019-??-??) +Version 0.8.0 (2019-07-26) ========================== New features: