From bd3d82d224020a95dc4d114dee7ded541b7b5f46 Mon Sep 17 00:00:00 2001 From: Kevin Papst Date: Mon, 16 Dec 2019 16:23:03 +0100 Subject: [PATCH] fix daterange filter for multi-timezone setups (#1321) --- README.md | 2 +- UPGRADING.md | 5 + config/packages/doctrine/mysql.yaml | 119 ++++++++++++++++++ config/packages/doctrine/sqlite.yaml | 56 +++++++++ .../Compiler/DoctrineCompilerPass.php | 12 +- src/Doctrine/Query/Sqlite/ConvertTz.php | 57 +++++++++ src/Repository/TimesheetRepository.php | 29 +++-- templates/export/index.html.twig | 1 + templates/invoice/index.html.twig | 1 + 9 files changed, 259 insertions(+), 23 deletions(-) create mode 100644 config/packages/doctrine/mysql.yaml create mode 100644 config/packages/doctrine/sqlite.yaml create mode 100644 src/Doctrine/Query/Sqlite/ConvertTz.php diff --git a/README.md b/README.md index a41b34851..314bb156c 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ It is built with modern technologies such as Symfony, Bootstrap, RESTful API, Do ### Requirements - PHP 7.2 or higher -- Database (MySQL, MariaDB, SQLite) +- Database (MySQL/MariaDB with timezone data, SQLite for development) - Webserver (nginx, Apache) - A modern browser - [Other libraries](https://www.kimai.org/download/) diff --git a/UPGRADING.md b/UPGRADING.md index b267acaa5..6986d6e2f 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -8,6 +8,11 @@ you can upgrade your Kimai installation to the latest stable release. Check below if there are more version specific steps required, which need to be executed after the normal update process. Perform EACH version specific task between your version and the new one, otherwise you risk data inconsistency or a broken installation. +## [1.7](https://github.com/kevinpapst/kimai2/releases/tag/1.7) + +New feature requires the timezone date within MySQL/MariaDB. +This is especially important for self-hosted systems [where you need to import the data manually](https://mariadb.com/kb/en/library/mysql_tzinfo_to_sql/). + ## [1.6](https://github.com/kevinpapst/kimai2/releases/tag/1.6), [1.6.1](https://github.com/kevinpapst/kimai2/releases/tag/1.6.1), [1.6.2](https://github.com/kevinpapst/kimai2/releases/tag/1.6.2) **New database tables and fields were created, don't forget to [run the updater](https://www.kimai.org/documentation/updates.html).** diff --git a/config/packages/doctrine/mysql.yaml b/config/packages/doctrine/mysql.yaml new file mode 100644 index 000000000..cbe88e915 --- /dev/null +++ b/config/packages/doctrine/mysql.yaml @@ -0,0 +1,119 @@ +doctrine: + orm: + dql: + datetime_functions: + addtime: DoctrineExtensions\Query\Mysql\AddTime + convert_tz: DoctrineExtensions\Query\Mysql\ConvertTz + date: DoctrineExtensions\Query\Mysql\Date + date_format: DoctrineExtensions\Query\Mysql\DateFormat + dateadd: DoctrineExtensions\Query\Mysql\DateAdd + datesub: DoctrineExtensions\Query\Mysql\DateSub + datediff: DoctrineExtensions\Query\Mysql\DateDiff + day: DoctrineExtensions\Query\Mysql\Day + dayname: DoctrineExtensions\Query\Mysql\DayName + dayofweek: DoctrineExtensions\Query\Mysql\DayOfWeek + dayofyear: DoctrineExtensions\Query\Mysql\DayOfYear + div: DoctrineExtensions\Query\Mysql\Div + from_unixtime: DoctrineExtensions\Query\Mysql\FromUnixtime + hour: DoctrineExtensions\Query\Mysql\Hour + last_day: DoctrineExtensions\Query\Mysql\LastDay + makedate: DoctrineExtensions\Query\Mysql\MakeDate + minute: DoctrineExtensions\Query\Mysql\Minute + now: DoctrineExtensions\Query\Mysql\Now + month: DoctrineExtensions\Query\Mysql\Month + monthname: DoctrineExtensions\Query\Mysql\MonthName + period_diff: DoctrineExtensions\Query\Mysql\PeriodDiff + second: DoctrineExtensions\Query\Mysql\Second + sectotime: DoctrineExtensions\Query\Mysql\SecToTime + strtodate: DoctrineExtensions\Query\Mysql\StrToDate + time: DoctrineExtensions\Query\Mysql\Time + timediff: DoctrineExtensions\Query\Mysql\TimeDiff + timestampadd: DoctrineExtensions\Query\Mysql\TimestampAdd + timestampdiff: DoctrineExtensions\Query\Mysql\TimestampDiff + timetosec: DoctrineExtensions\Query\Mysql\TimeToSec + week: DoctrineExtensions\Query\Mysql\Week + weekday: DoctrineExtensions\Query\Mysql\WeekDay + year: DoctrineExtensions\Query\Mysql\Year + yearmonth: DoctrineExtensions\Query\Mysql\YearMonth + yearweek: DoctrineExtensions\Query\Mysql\YearWeek + unix_timestamp: DoctrineExtensions\Query\Mysql\UnixTimestamp + utc_timestamp: DoctrineExtensions\Query\Mysql\UtcTimestamp + extract: DoctrineExtensions\Query\Mysql\Extract + + numeric_functions: + acos: DoctrineExtensions\Query\Mysql\Acos + asin: DoctrineExtensions\Query\Mysql\Asin + atan2: DoctrineExtensions\Query\Mysql\Atan2 + atan: DoctrineExtensions\Query\Mysql\Atan + bit_count: DoctrineExtensions\Query\Mysql\BitCount + bit_xor: DoctrineExtensions\Query\Mysql\BitXor + ceil: DoctrineExtensions\Query\Mysql\Ceil + cos: DoctrineExtensions\Query\Mysql\Cos + cot: DoctrineExtensions\Query\Mysql\Cot + degrees: DoctrineExtensions\Query\Mysql\Degrees + exp: DoctrineExtensions\Query\Mysql\Exp + floor: DoctrineExtensions\Query\Mysql\Floor + log: DoctrineExtensions\Query\Mysql\Log + log10: DoctrineExtensions\Query\Mysql\Log10 + log2: DoctrineExtensions\Query\Mysql\Log2 + pi: DoctrineExtensions\Query\Mysql\Pi + power: DoctrineExtensions\Query\Mysql\Power + quarter: DoctrineExtensions\Query\Mysql\Quarter + radians: DoctrineExtensions\Query\Mysql\Radians + rand: DoctrineExtensions\Query\Mysql\Rand + round: DoctrineExtensions\Query\Mysql\Round + stddev: DoctrineExtensions\Query\Mysql\StdDev + sin: DoctrineExtensions\Query\Mysql\Sin + std: DoctrineExtensions\Query\Mysql\Std + tan: DoctrineExtensions\Query\Mysql\Tan + variance: DoctrineExtensions\Query\Mysql\Variance + + string_functions: + aes_decrypt: DoctrineExtensions\Query\Mysql\AesDecrypt + aes_encrypt: DoctrineExtensions\Query\Mysql\AesEncrypt + any_value: DoctrineExtensions\Query\Mysql\AnyValue + ascii: DoctrineExtensions\Query\Mysql\Ascii + binary: DoctrineExtensions\Query\Mysql\Binary + cast: DoctrineExtensions\Query\Mysql\Cast + char_length: DoctrineExtensions\Query\Mysql\CharLength + collate: DoctrineExtensions\Query\Mysql\Collate + concat_ws: DoctrineExtensions\Query\Mysql\ConcatWs + countif: DoctrineExtensions\Query\Mysql\CountIf + crc32: DoctrineExtensions\Query\Mysql\Crc32 + degrees: DoctrineExtensions\Query\Mysql\Degrees + field: DoctrineExtensions\Query\Mysql\Field + find_in_set: DoctrineExtensions\Query\Mysql\FindInSet + format: DoctrineExtensions\Query\Mysql\Format + greatest: DoctrineExtensions\Query\Mysql\Greatest + group_concat: DoctrineExtensions\Query\Mysql\GroupConcat + hex: DoctrineExtensions\Query\Mysql\Hex + ifelse: DoctrineExtensions\Query\Mysql\IfElse + ifnull: DoctrineExtensions\Query\Mysql\IfNull + inet_aton: DoctrineExtensions\Query\Mysql\InetAton + inet_ntoa: DoctrineExtensions\Query\Mysql\InetNtoa + inet6_aton: DoctrineExtensions\Query\Mysql\Inet6Aton + inet6_ntoa: DoctrineExtensions\Query\Mysql\Inet6Ntoa + instr: DoctrineExtensions\Query\Mysql\Instr + is_ipv4: DoctrineExtensions\Query\Mysql\IsIpv4 + is_ipv4_compat: DoctrineExtensions\Query\Mysql\IsIpv4Compat + is_ipv4_mapped: DoctrineExtensions\Query\Mysql\IsIpv4Mapped + is_ipv6: DoctrineExtensions\Query\Mysql\IsIpv6 + lag: DoctrineExtensions\Query\Mysql\Lag + lead: DoctrineExtensions\Query\Mysql\Lead + least: DoctrineExtensions\Query\Mysql\Least + lpad: DoctrineExtensions\Query\Mysql\Lpad + match: DoctrineExtensions\Query\Mysql\MatchAgainst + md5: DoctrineExtensions\Query\Mysql\Md5 + nullif: DoctrineExtensions\Query\Mysql\NullIf + over: DoctrineExtensions\Query\Mysql\Over + radians: DoctrineExtensions\Query\Mysql\Radians + regexp: DoctrineExtensions\Query\Mysql\Regexp + replace: DoctrineExtensions\Query\Mysql\Replace + rpad: DoctrineExtensions\Query\Mysql\Rpad + sha1: DoctrineExtensions\Query\Mysql\Sha1 + sha2: DoctrineExtensions\Query\Mysql\Sha2 + soundex: DoctrineExtensions\Query\Mysql\Soundex + str_to_date: DoctrineExtensions\Query\Mysql\StrToDate + substring_index: DoctrineExtensions\Query\Mysql\SubstringIndex + unhex: DoctrineExtensions\Query\Mysql\Unhex + uuid_short: DoctrineExtensions\Query\Mysql\UuidShort diff --git a/config/packages/doctrine/sqlite.yaml b/config/packages/doctrine/sqlite.yaml new file mode 100644 index 000000000..543493680 --- /dev/null +++ b/config/packages/doctrine/sqlite.yaml @@ -0,0 +1,56 @@ +doctrine: + orm: + dql: + datetime_functions: + convert_tz: App\Doctrine\Query\Sqlite\ConvertTz + date: DoctrineExtensions\Query\Sqlite\Date + date_format: DoctrineExtensions\Query\Sqlite\DateFormat + #dateadd: DoctrineExtensions\Query\Sqlite\DateAdd + #datediff: DoctrineExtensions\Query\Sqlite\DateDiff + day: DoctrineExtensions\Query\Sqlite\Day + dayname: DoctrineExtensions\Query\Sqlite\DayName + hour: DoctrineExtensions\Query\Sqlite\Hour + julianday: DoctrineExtensions\Query\Sqlite\JulianDay + minute: DoctrineExtensions\Query\Sqlite\Minute + #month: DoctrineExtensions\Query\Sqlite\Month + #monthname: DoctrineExtensions\Query\Sqlite\MonthName + second: DoctrineExtensions\Query\Sqlite\Second + month: DoctrineExtensions\Query\Sqlite\Month + strftime: DoctrineExtensions\Query\Sqlite\StrfTime + #strtodate: DoctrineExtensions\Query\Sqlite\StrToDate + #time: DoctrineExtensions\Query\Sqlite\Time + #timestampadd: DoctrineExtensions\Query\Sqlite\TimestampAdd + #timestampdiff: DoctrineExtensions\Query\Sqlite\TimestampDiff + week: DoctrineExtensions\Query\Sqlite\Week + weekday: DoctrineExtensions\Query\Sqlite\WeekDay + year: DoctrineExtensions\Query\Sqlite\Year + + + string_functions: +# binary: DoctrineExtensions\Query\Sqlite\Binary +# char_length: DoctrineExtensions\Query\Sqlite\CharLength + concat_ws: DoctrineExtensions\Query\Sqlite\ConcatWs +# countif: DoctrineExtensions\Query\Sqlite\CountIf +# crc32: DoctrineExtensions\Query\Sqlite\Crc32 +# degrees: DoctrineExtensions\Query\Sqlite\Degrees +# field: DoctrineExtensions\Query\Sqlite\Field +# find_in_set: DoctrineExtensions\Query\Sqlite\FindInSet +# group_concat: DoctrineExtensions\Query\Sqlite\GroupConcat + greatest: DoctrineExtensions\Query\Sqlite\Greatest + ifelse: DoctrineExtensions\Query\Sqlite\IfElse + ifnull: DoctrineExtensions\Query\Sqlite\IfNull + least: DoctrineExtensions\Query\Sqlite\Least +# match: DoctrineExtensions\Query\Sqlite\MatchAgainst +# md5: DoctrineExtensions\Query\Sqlite\Md5 +# nullif: DoctrineExtensions\Query\Sqlite\NullIf +# radians: DoctrineExtensions\Query\Sqlite\Radians + random: DoctrineExtensions\Query\Sqlite\Random +# regexp: DoctrineExtensions\Query\Sqlite\Regexp + replace: DoctrineExtensions\Query\Sqlite\Replace +# sha1: DoctrineExtensions\Query\Sqlite\Sha1 +# sha2: DoctrineExtensions\Query\Sqlite\Sha2 +# soundex: DoctrineExtensions\Query\Sqlite\Soundex +# uuid_short: DoctrineExtensions\Query\Sqlite\UuidShort + + numeric_functions: + round: DoctrineExtensions\Query\Sqlite\Round diff --git a/src/DependencyInjection/Compiler/DoctrineCompilerPass.php b/src/DependencyInjection/Compiler/DoctrineCompilerPass.php index ae00e189c..7677243c6 100644 --- a/src/DependencyInjection/Compiler/DoctrineCompilerPass.php +++ b/src/DependencyInjection/Compiler/DoctrineCompilerPass.php @@ -70,18 +70,10 @@ class DoctrineCompilerPass implements CompilerPassInterface $engine = $this->findEngine(); $configDir = realpath( - $container->getParameter('kernel.project_dir') . '/vendor/beberlei/doctrineextensions/config/' + $container->getParameter('kernel.project_dir') . '/config/packages/doctrine/' ); - if (!file_exists($configDir)) { - @trigger_error('Using deprecated doctrine extensions config directory', E_USER_DEPRECATED); - - $configDir = realpath( - $container->getParameter('kernel.project_dir') . '/vendor/beberlei/DoctrineExtensions/config/' - ); - } - - $configFile = $configDir . '/' . $engine . '.yml'; + $configFile = $configDir . '/' . $engine . '.yaml'; if (!file_exists($configFile)) { throw new \Exception('Could not find config file for database engine. Looked at ' . $configFile); diff --git a/src/Doctrine/Query/Sqlite/ConvertTz.php b/src/Doctrine/Query/Sqlite/ConvertTz.php new file mode 100644 index 000000000..a7a69637d --- /dev/null +++ b/src/Doctrine/Query/Sqlite/ConvertTz.php @@ -0,0 +1,57 @@ +walkArithmeticExpression($this->dateExpression); + $fromTz = $sqlWalker->walkStringPrimary($this->fromTz); + $toTz = $sqlWalker->walkStringPrimary($this->toTz); + + return sprintf('%s', $fieldName); + } + + /** + * {@inheritdoc} + */ + public function parse(Parser $parser) + { + $parser->match(Lexer::T_IDENTIFIER); + $parser->match(Lexer::T_OPEN_PARENTHESIS); + + $this->dateExpression = $parser->ArithmeticExpression(); + $parser->match(Lexer::T_COMMA); + + $this->fromTz = $parser->StringPrimary(); + $parser->match(Lexer::T_COMMA); + + $this->toTz = $parser->StringPrimary(); + $parser->match(Lexer::T_CLOSE_PARENTHESIS); + } +} diff --git a/src/Repository/TimesheetRepository.php b/src/Repository/TimesheetRepository.php index d5d0ce98d..a211b9ef5 100644 --- a/src/Repository/TimesheetRepository.php +++ b/src/Repository/TimesheetRepository.php @@ -218,14 +218,14 @@ class TimesheetRepository extends EntityRepository if (!empty($begin)) { $qb - ->andWhere($qb->expr()->gt('t.begin', ':from')) - ->setParameter('from', $begin, Type::DATETIME); + ->andWhere($qb->expr()->gte($this->getDatetimeFieldSql('t.begin'), ':from')) + ->setParameter('from', $begin); } if (!empty($end)) { $qb - ->andWhere($qb->expr()->lt('t.end', ':to')) - ->setParameter('to', $end, Type::DATETIME); + ->andWhere($qb->expr()->lte($this->getDatetimeFieldSql('t.end'), ':to')) + ->setParameter('to', $end); } if (null !== $user) { @@ -285,15 +285,15 @@ class TimesheetRepository extends EntityRepository ; if (!empty($begin)) { - $qb->where($qb->expr()->gt('t.begin', ':from')); - $qb->setParameter('from', $begin, Type::DATETIME); + $qb->andWhere($qb->expr()->gte($this->getDatetimeFieldSql('t.begin'), ':from')) + ->setParameter('from', $begin); } else { - $qb->where($qb->expr()->isNotNull('t.begin')); + $qb->andWhere($qb->expr()->isNotNull('t.begin')); } if (!empty($end)) { - $qb->andWhere($qb->expr()->lt('t.end', ':to')) - ->setParameter('to', $end, Type::DATETIME); + $qb->andWhere($qb->expr()->lte($this->getDatetimeFieldSql('t.end'), ':to')) + ->setParameter('to', $end); } else { $qb->andWhere($qb->expr()->isNotNull('t.end')); } @@ -674,7 +674,7 @@ class TimesheetRepository extends EntityRepository } if (null !== $query->getBegin()) { - $qb->andWhere('t.begin >= :begin') + $qb->andWhere($qb->expr()->gte($this->getDatetimeFieldSql('t.begin'), ':begin')) ->setParameter('begin', $query->getBegin()); } @@ -685,7 +685,7 @@ class TimesheetRepository extends EntityRepository } if (null !== $query->getEnd()) { - $qb->andWhere('t.begin <= :end') + $qb->andWhere($qb->expr()->lte($this->getDatetimeFieldSql('t.begin'), ':end')) ->setParameter('end', $query->getEnd()); } @@ -782,7 +782,7 @@ class TimesheetRepository extends EntityRepository } if (null !== $startFrom) { - $qb->andWhere($qb->expr()->gt('t.begin', ':begin')) + $qb->andWhere($qb->expr()->gte($this->getDatetimeFieldSql('t.begin'), ':begin')) ->setParameter('begin', $startFrom); } @@ -824,4 +824,9 @@ class TimesheetRepository extends EntityRepository $em->commit(); } + + private function getDatetimeFieldSql(string $field): string + { + return sprintf('CONVERT_TZ(%s, \'UTC\', t.timezone)', $field); + } } diff --git a/templates/export/index.html.twig b/templates/export/index.html.twig index 740d77d3b..a78f286e1 100644 --- a/templates/export/index.html.twig +++ b/templates/export/index.html.twig @@ -30,6 +30,7 @@ {% block box_title %}{{ 'export.filter'|trans }}{% endblock %} {% block box_before %}{{ form_start(form) }}{% endblock %} {% block box_body %} + {{ form_errors(form) }} {{ form_row(form.searchTerm) }} {{ form_row(form.daterange) }} {{ form_row(form.customer) }} diff --git a/templates/invoice/index.html.twig b/templates/invoice/index.html.twig index 5641d53ac..0f85d39ec 100644 --- a/templates/invoice/index.html.twig +++ b/templates/invoice/index.html.twig @@ -32,6 +32,7 @@ {% block box_title %}{{ 'invoice.filter'|trans }}{% endblock %} {% block box_before %}{{ form_start(form) }}{% endblock %} {% block box_body %} + {{ form_errors(form) }} {{ form_row(form.searchTerm) }} {{ form_row(form.daterange) }} {{ form_row(form.customer) }}