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 a41b3485..314bb156 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 b267acaa..6986d6e2 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 00000000..cbe88e91 --- /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 00000000..54349368 --- /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 ae00e189..7677243c 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 00000000..a7a69637 --- /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 d5d0ce98..a211b9ef 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 740d77d3..a78f286e 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 5641d53a..0f85d39e 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) }}