Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.65% covered (success)
98.65%
73 / 74
90.00% covered (success)
90.00%
9 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
DateRangeFeature
98.65% covered (success)
98.65%
73 / 74
90.00% covered (success)
90.00%
9 / 10
28
0.00% covered (danger)
0.00%
0 / 1
 factory
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getFilterQuery
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseNow
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 parseDate
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 parseValue
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 formatDate
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 doGetFilterQuery
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 getKeywords
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doApply
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace CirrusSearch\Query;
4
5use CirrusSearch\Parser\AST\KeywordFeatureNode;
6use CirrusSearch\Query\Builder\QueryBuildingContext;
7use CirrusSearch\Search\SearchContext;
8use CirrusSearch\SearchConfig;
9use CirrusSearch\WarningCollector;
10use DateTime;
11use DateTimeZone;
12use Elastica\Query\Range;
13use MediaWiki\Config\Config;
14use MediaWiki\MainConfigNames;
15
16/**
17 * Support for date range queries.
18 *
19 * Examples:
20 *   lasteditdate:>=now-1d
21 */
22class DateRangeFeature extends SimpleKeywordFeature implements FilterQueryFeature {
23    /**
24     * @var array Mapping from syntax prefix to range query param key
25     */
26    private static $PREFIXES = [
27        // two char must come before one char variants
28        '<=' => 'lte',
29        '<' => 'lt',
30        '>=' => 'gte',
31        '>' => 'gt'
32    ];
33
34    /**
35     * @var array[] Configuration of supported date formats
36     */
37    private static $DATE_FORMAT = [
38        [
39            // php format
40            'php' => 'Y',
41            // opensearch format
42            'opensearch' => 'year',
43            // precision to round query to
44            'precision' => 'y'
45        ],
46        [
47            'php' => 'Y-m',
48            'opensearch' => 'year_month',
49            'precision' => 'M', // upper case for months
50        ],
51        [
52            'php' => 'Y-m-d',
53            'opensearch' => 'date',
54            'precision' => 'd',
55        ],
56    ];
57
58    /**
59     * @var string The keyword to respond to
60     */
61    private string $keyword;
62
63    /**
64     * @var string The field to range query against
65     */
66    private string $fieldName;
67
68    /**
69     * @var string The timezone to use for date parsing and rounding
70     */
71    private string $tz;
72
73    /**
74     * @param Config $config MediaWiki configuration to source tz from
75     * @param string $keyword The keyword to respond to
76     * @param string $fieldName The field to range query against
77     * @return DateRangeFeature
78     */
79    public static function factory(
80        Config $config,
81        string $keyword,
82        string $fieldName
83    ): DateRangeFeature {
84        return new self(
85            $keyword,
86            $fieldName,
87            $config->get( MainConfigNames::Localtimezone ) ?? 'UTC',
88        );
89    }
90
91    /**
92     * @param string $keyword The keyword to responsd to
93     * @param string $fieldName The field to range query against
94     * @param string $tz The timezone to use for date parsing and rounding
95     */
96    public function __construct( string $keyword, string $fieldName, string $tz ) {
97        $this->keyword = $keyword;
98        $this->fieldName = $fieldName;
99        $this->tz = $tz;
100    }
101
102    /** @inheritDoc */
103    public function getFilterQuery( KeywordFeatureNode $node, QueryBuildingContext $context ) {
104        return $this->doGetFilterQuery( $node->getParsedValue(), $context->getSearchConfig() );
105    }
106
107    private function parseNow( string $value ): ?array {
108        if ( preg_match( '/^(now|today)(?:-(\d+)([ymdh]))?$/', $value, $matches ) !== 1 ) {
109            return null;
110        }
111
112        $date = [
113            'value' => 'now',
114            'precision' => $matches[1] === 'now' ? 'h' : 'd',
115        ];
116        if ( isset( $matches[2] ) ) {
117            $date['subtract'] = [ $matches[2], $matches[3] ];
118            if ( $date['subtract'][1] === 'm' ) {
119                // m is minutes, we want M for months.
120                $date['subtract'][1] = 'M';
121            }
122        }
123        return $date;
124    }
125
126    private function parseDate( string $value ): ?array {
127        $tz = new DateTimeZone( $this->tz );
128        foreach ( self::$DATE_FORMAT as $settings ) {
129            $dt = DateTime::createFromFormat( $settings['php'], $value, $tz );
130            // must not only parse, but round trip. Avoids things like
131            // 2025-15 parsing as 2026-03.
132            if ( $dt !== false && $dt->format( $settings['php'] ) === $value ) {
133                return [
134                    'format' => $settings['opensearch'],
135                    'value' => $value,
136                    'precision' => $settings['precision'],
137                ];
138            }
139        }
140        return null;
141    }
142
143    /** @inheritDoc */
144    public function parseValue( $key, $value, $quotedValue, $valueDelimiter, $suffix, WarningCollector $warningCollector ) {
145        // Parse out the prefix, specifying direction of the condition
146        $cond = 'eq';
147        foreach ( self::$PREFIXES as $prefix => $condition ) {
148            if ( str_starts_with( $value, $prefix ) ) {
149                $cond = $condition;
150                $value = substr( $value, strlen( $prefix ) );
151                break;
152            }
153        }
154        // Remaining text must be either a date or now
155        $date = $this->parseNow( $value ) ?? $this->parseDate( $value );
156        if ( $date === null ) {
157            $warningCollector->addWarning( 'cirrussearch-feature-invalid-date-range' );
158            return [];
159        }
160        return [
161            'condition' => $cond,
162            'date' => $date,
163        ];
164    }
165
166    private function formatDate( array $date ): string {
167        $math = '';
168        if ( $date['subtract'] ?? null ) {
169            [ $count, $unit ] = $date['subtract'];
170            $math .= "-{$count}{$unit}";
171        }
172        if ( $date['precision'] ?? null ) {
173            $math .= '/' . $date['precision'];
174        }
175
176        if ( $date['value'] === 'now' ) {
177            return "now{$math}";
178        } elseif ( $math ) {
179            return "{$date['value']}||{$math}";
180        } else {
181            return $date['value'];
182        }
183    }
184
185    /** @inheritDoc */
186    protected function doGetFilterQuery( array $parsedValue, SearchConfig $searchConfig ) {
187        if ( !$parsedValue ) {
188            return null;
189        }
190
191        // timezone will be used for parsing and rounding
192        $params = [ 'time_zone' => $this->tz ];
193        // "now" doesn't have a format
194        if ( $parsedValue['date']['value'] !== 'now' ) {
195            $params['format'] = $parsedValue['date']['format'];
196        }
197        $date = $this->formatDate( $parsedValue['date'] );
198        if ( $parsedValue['condition'] == 'eq' ) {
199            $params['gte'] = $date;
200            $params['lte'] = $date;
201        } else {
202            $params[$parsedValue['condition']] = $date;
203        }
204
205        return new Range( $this->fieldName, $params );
206    }
207
208    /** @inheritDoc */
209    protected function getKeywords() {
210        return [ $this->keyword ];
211    }
212
213    /** @inheritDoc */
214    protected function doApply( SearchContext $context, $key, $value, $quotedValue, $negated ) {
215        $filter = $this->doGetFilterQuery(
216            $this->parseValue( $key, $value, $quotedValue, '', '', $context ),
217            $context->getConfig()
218        );
219        if ( !$filter ) {
220            $context->setResultsPossible( false );
221        }
222        return [ $filter, false ];
223    }
224}