├── .github └── workflows │ └── dart.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── analysis_options.yaml ├── example └── example.dart ├── lib ├── query.dart └── src │ ├── ast.dart │ └── grammar.dart ├── pubspec.yaml └── test └── query_test.dart /.github/workflows/dart.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | name: Dart 7 | 8 | on: 9 | push: 10 | branches: [ master ] 11 | pull_request: 12 | branches: [ master ] 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | 21 | # Note: This workflow uses the latest stable version of the Dart SDK. 22 | # You can specify other versions if desired, see documentation here: 23 | # https://github.com/dart-lang/setup-dart/blob/main/README.md 24 | # - uses: dart-lang/setup-dart@v1 25 | - uses: dart-lang/setup-dart@9a04e6d73cca37bd455e0608d7e5092f881fd603 26 | 27 | - name: Install dependencies 28 | run: dart pub get 29 | 30 | # Uncomment this step to verify the use of 'dart format' on each commit. 31 | - name: Verify formatting 32 | run: dart format --output=none --set-exit-if-changed . 33 | 34 | # Consider passing '--fatal-infos' for slightly stricter analysis. 35 | - name: Analyze project source 36 | run: dart analyze 37 | 38 | # Your project will need to have tests in test/ and a dependency on 39 | # package:test for this step to succeed. Note that Flutter projects will 40 | # want to change this to 'flutter test'. 41 | - name: Run tests 42 | run: dart test 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Files and directories created by pub 2 | .dart_tool/ 3 | .packages 4 | # Remove the following pattern if you wish to check in your lock file 5 | pubspec.lock 6 | 7 | # Conventional directory for build outputs 8 | build/ 9 | 10 | # Directory created by dartdoc 11 | doc/api/ 12 | 13 | # IDE 14 | *.iml 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 2.2.0 2 | 3 | - Make `Query` sealed instead of abstract. 4 | 5 | ## 2.1.1 6 | 7 | - Updated `petitparser` dependency. 8 | 9 | ## 2.1.0 10 | 11 | - Naming consistency refactor: 12 | - `ScopeQuery` is returned instead of `FieldScope`. 13 | - `CompareQuery` is returned instead of `FieldCompareQuery`. 14 | - Old classes are kept in compatibility mode and are deprecated. 15 | - Added `QueryEvaluator` to help evaluating queries. 16 | 17 | ## 2.0.0 18 | 19 | **Breaking changes** 20 | - `FieldScope.field`, `FieldCompareQuery.field` and `FieldCompareQuery.operator` are now a `TextQuery`. 21 | - `field:` and `field `: `` can now be an empty string. 22 | - `TextQuery.isExact` and `PhraseQuery.isExact` are removed. `PhraseQuery` is always exact and `TextQuery` never is. 23 | 24 | **Updated** 25 | - Added `SourcePosition` to `Query`, storing the `start` and `end` index of the matching input. 26 | 27 | Thanks to [North101](https://github.com/North101) working on [#9](https://github.com/isoos/query/pull/9) to make this happen! 28 | 29 | ## 1.6.0 30 | 31 | - Upgraded `petitparser` to `5.0.0`. 32 | 33 | ## 1.5.0 34 | 35 | - Migrated to new `petitparser` API (`ref`). 36 | - Deprecated non-public API `QueryParser`, use `QueryGrammarDefinition.build` instead. 37 | - Fixed grammar issues [#6](https://github.com/isoos/query/pull/6) by [North101](https://github.com/North101). 38 | 39 | ## 1.4.0 40 | 41 | - Migrated to null safety. 42 | 43 | ## 1.3.0 44 | 45 | - nested groups 46 | - `|` as alias for `OR` 47 | - phrase search expression contain the separate words/phrases as a children list 48 | 49 | Thanks to [edyu](https://github.com/edyu) on [#3](https://github.com/isoos/query/pull/3). 50 | 51 | ## 1.2.0 52 | 53 | - Update sources to Dart 2.3 SDK and lints. 54 | 55 | ## 1.1.2 56 | 57 | - More lints and checks in `analysis_options.yaml`. 58 | 59 | ## 1.1.1 60 | 61 | - Support for new `petitparser` API. 62 | 63 | ## 1.1.0 64 | 65 | - Support for non-ASCII characters in scope and words. 66 | 67 | ## 1.0.0 68 | 69 | - Initial version. 70 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018, the project authors. All rights reserved. 2 | Redistribution and use in source and binary forms, with or without 3 | modification, are permitted provided that the following conditions are 4 | met: 5 | 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above 9 | copyright notice, this list of conditions and the following 10 | disclaimer in the documentation and/or other materials provided 11 | with the distribution. 12 | * Neither the name of the project nor the names of its 13 | contributors may be used to endorse or promote products derived 14 | from this software without specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 17 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 18 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 19 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 20 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 21 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 22 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 23 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 24 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 26 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Search query parser library 2 | 3 | The library helps to parse search queries (e.g. custom search boxes) 4 | and enables custom search index implementations. 5 | 6 | Supported expressions: 7 | 8 | - (Implicit) boolean AND: `a AND b` or `a b` 9 | - boolean OR: `a OR b OR c` 10 | - boolean NOT: `-a` or `NOT a` 11 | - group query: `(a b) OR (c d)` 12 | - text match: `abc` or `"words in close proximity"` 13 | - range query: `[1 TO 20]` (inclusive), `]aaa TO dzz[` (exclusive), or `[1 TO 20[` (mixed) 14 | - scopes: `field:(a b)` or `field:abc` 15 | - field comparison: `year < 2000` 16 | 17 | ## Usage 18 | 19 | A simple usage example: 20 | 21 | ```dart 22 | import 'package:query/query.dart'; 23 | 24 | main() { 25 | final q = parseQuery('some text OR field:another'); 26 | // prints "(some (text OR field:another))" 27 | print(q); 28 | } 29 | ``` 30 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:lints/recommended.yaml 2 | analyzer: 3 | strong-mode: 4 | implicit-casts: false 5 | 6 | # Lint rules and documentation, see http://dart-lang.github.io/linter/lints 7 | linter: 8 | rules: 9 | - cancel_subscriptions 10 | - hash_and_equals 11 | - collection_methods_unrelated_type 12 | - test_types_in_equals 13 | - unrelated_type_equality_checks 14 | - valid_regexps 15 | -------------------------------------------------------------------------------- /example/example.dart: -------------------------------------------------------------------------------- 1 | import 'package:query/query.dart'; 2 | 3 | void main() { 4 | final q = parseQuery('some text OR field:another'); 5 | // prints "(some (text OR field:another))" 6 | print(q); 7 | } 8 | -------------------------------------------------------------------------------- /lib/query.dart: -------------------------------------------------------------------------------- 1 | import 'src/ast.dart'; 2 | import 'src/grammar.dart'; 3 | 4 | export 'src/ast.dart'; 5 | 6 | final _parser = QueryGrammarDefinition().build(); 7 | 8 | /// Parses [input] and returns a parsed [Query]. 9 | Query parseQuery(String input) { 10 | return _parser.parse(input).value; 11 | } 12 | -------------------------------------------------------------------------------- /lib/src/ast.dart: -------------------------------------------------------------------------------- 1 | /// A class that describes the position of the source text. 2 | class SourcePosition { 3 | const SourcePosition(this.start, this.end); 4 | 5 | /// The start position of this query. 6 | final int start; 7 | 8 | /// The end position of this query, exclusive. 9 | final int end; 10 | 11 | // The length of this query, in characters. 12 | int get length => end - start; 13 | } 14 | 15 | /// Provides an interface for generic query evaluation. 16 | abstract class QueryEvaluator { 17 | R evalText(TextQuery query); 18 | R evalPhrase(PhraseQuery query); 19 | R evalScope(ScopeQuery query); 20 | R evalCompare(CompareQuery query); 21 | R evalRange(RangeQuery query); 22 | R evalNot(NotQuery query); 23 | R evalGroup(GroupQuery query); 24 | R evalAnd(AndQuery query); 25 | R evalOr(OrQuery query); 26 | } 27 | 28 | /// Base interface for queries. 29 | sealed class Query { 30 | const Query({ 31 | required this.position, 32 | }); 33 | 34 | /// The position of this query relative to the source. 35 | final SourcePosition position; 36 | 37 | /// Returns a String-representation of this [Query]. 38 | /// 39 | /// Implementation should aim to provide a format that can be parsed to the 40 | /// same form. 41 | /// 42 | /// [debug] is used to extend the format with additional characters, making 43 | /// testing unambiguous. 44 | @override 45 | String toString({bool debug = false}); 46 | 47 | /// Returns this [Query] cast as [R] 48 | /// 49 | /// If the [Query] cannot be cast to [R] it will throw an exception. 50 | R cast() => this as R; 51 | 52 | R eval(QueryEvaluator evaluator); 53 | } 54 | 55 | /// Text query to match [text]. 56 | class TextQuery extends Query { 57 | final String text; 58 | const TextQuery({ 59 | required this.text, 60 | required super.position, 61 | }); 62 | 63 | @override 64 | R eval(QueryEvaluator evaluator) => evaluator.evalText(this); 65 | 66 | @override 67 | String toString({bool debug = false}) => _debug(debug, text); 68 | } 69 | 70 | /// Phrase query to match "[text]" for a list of words inside quotes. 71 | class PhraseQuery extends TextQuery { 72 | final List children; 73 | const PhraseQuery({ 74 | required super.text, 75 | required this.children, 76 | required super.position, 77 | }); 78 | 79 | @override 80 | R eval(QueryEvaluator evaluator) => evaluator.evalPhrase(this); 81 | 82 | @override 83 | String toString({bool debug = false}) => 84 | '"${children.map((n) => n.toString(debug: debug)).join(' ')}"'; 85 | } 86 | 87 | /// Scopes [child] [Query] to be applied only on the [field]. 88 | @Deprecated('Use ScopeQuery instead.') 89 | class FieldScope extends Query { 90 | final TextQuery field; 91 | final Query child; 92 | 93 | const FieldScope({ 94 | required this.field, 95 | required this.child, 96 | required super.position, 97 | }); 98 | 99 | @override 100 | R eval(QueryEvaluator evaluator) => evaluator 101 | .evalScope(ScopeQuery(field: field, child: child, position: position)); 102 | 103 | @override 104 | String toString({bool debug = false}) => 105 | '$field:${child.toString(debug: debug)}'; 106 | } 107 | 108 | /// Scopes [child] [Query] to be applied only on the [field]. 109 | // ignore: deprecated_member_use_from_same_package 110 | class ScopeQuery extends FieldScope { 111 | const ScopeQuery({ 112 | required super.field, 113 | required super.child, 114 | required super.position, 115 | }); 116 | 117 | @override 118 | R eval(QueryEvaluator evaluator) => evaluator.evalScope(this); 119 | } 120 | 121 | /// Describes a [field] [operator] [text] tripled (e.g. year < 2000). 122 | @Deprecated('Use CompareQuery instead.') 123 | class FieldCompareQuery extends Query { 124 | final TextQuery field; 125 | final TextQuery operator; 126 | final TextQuery text; 127 | 128 | const FieldCompareQuery({ 129 | required this.field, 130 | required this.operator, 131 | required this.text, 132 | required super.position, 133 | }); 134 | 135 | @override 136 | R eval(QueryEvaluator evaluator) => evaluator.evalCompare(CompareQuery( 137 | field: field, operator: operator, text: text, position: position)); 138 | 139 | @override 140 | String toString({bool debug = false}) => 141 | _debug(debug, '$field$operator$text'); 142 | } 143 | 144 | /// Describes a [field] [operator] [text] tripled (e.g. year < 2000). 145 | // ignore: deprecated_member_use_from_same_package 146 | class CompareQuery extends FieldCompareQuery { 147 | CompareQuery({ 148 | required super.field, 149 | required super.operator, 150 | required super.text, 151 | required super.position, 152 | }); 153 | 154 | @override 155 | R eval(QueryEvaluator evaluator) => evaluator.evalCompare(this); 156 | } 157 | 158 | /// Describes a range query between [start] and [end]. 159 | class RangeQuery extends Query { 160 | final TextQuery start; 161 | final bool startInclusive; 162 | final TextQuery end; 163 | final bool endInclusive; 164 | 165 | const RangeQuery({ 166 | required this.start, 167 | required this.end, 168 | required super.position, 169 | this.startInclusive = true, 170 | this.endInclusive = true, 171 | }); 172 | 173 | @override 174 | R eval(QueryEvaluator evaluator) => evaluator.evalRange(this); 175 | 176 | @override 177 | String toString({bool debug = false}) => _debug( 178 | debug, 179 | '${_sp(true, startInclusive)}${start.toString(debug: debug)} TO ' 180 | '${end.toString(debug: debug)}${_sp(false, endInclusive)}'); 181 | 182 | String _sp(bool start, bool inclusive) { 183 | return start ? (inclusive ? '[' : ']') : (inclusive ? ']' : '['); 184 | } 185 | } 186 | 187 | /// Negates the [child] query. (bool NOT) 188 | class NotQuery extends Query { 189 | final Query child; 190 | const NotQuery({ 191 | required this.child, 192 | required super.position, 193 | }); 194 | 195 | @override 196 | R eval(QueryEvaluator evaluator) => evaluator.evalNot(this); 197 | 198 | @override 199 | String toString({bool debug = false}) => '-${child.toString(debug: debug)}'; 200 | } 201 | 202 | /// Groups the [child] query to override implicit precedence. 203 | class GroupQuery extends Query { 204 | final Query child; 205 | const GroupQuery({ 206 | required this.child, 207 | required super.position, 208 | }); 209 | 210 | @override 211 | R eval(QueryEvaluator evaluator) => evaluator.evalGroup(this); 212 | 213 | @override 214 | String toString({bool debug = false}) => '(${child.toString(debug: debug)})'; 215 | } 216 | 217 | /// Bool AND composition of [children] queries. 218 | class AndQuery extends Query { 219 | final List children; 220 | 221 | const AndQuery({ 222 | required this.children, 223 | required super.position, 224 | }); 225 | 226 | @override 227 | R eval(QueryEvaluator evaluator) => evaluator.evalAnd(this); 228 | 229 | @override 230 | String toString({bool debug = false}) => 231 | '(${children.map((n) => n.toString(debug: debug)).join(' ')})'; 232 | } 233 | 234 | /// Bool OR composition of [children] queries. 235 | class OrQuery extends Query { 236 | final List children; 237 | 238 | const OrQuery({ 239 | required this.children, 240 | required super.position, 241 | }); 242 | 243 | @override 244 | R eval(QueryEvaluator evaluator) => evaluator.evalOr(this); 245 | 246 | @override 247 | String toString({bool debug = false}) => 248 | '(${children.map((n) => n.toString(debug: debug)).join(' OR ')})'; 249 | } 250 | 251 | String _debug(bool debug, String expr) => debug ? '<$expr>' : expr; 252 | -------------------------------------------------------------------------------- /lib/src/grammar.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: non_constant_identifier_names, deprecated_member_use 2 | 3 | import 'package:petitparser/petitparser.dart'; 4 | 5 | import 'ast.dart'; 6 | 7 | class QueryGrammarDefinition extends GrammarDefinition { 8 | const QueryGrammarDefinition(); 9 | 10 | @override 11 | Parser start() => ref0(root).end(); 12 | Parser token(Parser parser) => parser.flatten().trim(); 13 | 14 | // Handles AND sequences (where AND is optional) 15 | Parser root() { 16 | final g = 17 | ref0(or) & (ref0(rootSep) & ref0(or)).map((list) => list.last).star(); 18 | return g.token().map((list) { 19 | final children = [ 20 | list.value.first as Query, 21 | ...(list.value.last as List).cast(), 22 | ]; 23 | if (children.length == 1) return children.single; 24 | return AndQuery( 25 | children: children, position: SourcePosition(list.start, list.stop)); 26 | }); 27 | } 28 | 29 | Parser rootSep() => 30 | (ref0(EXP_SEP) & string('AND')).optional() & ref0(EXP_SEP); 31 | 32 | // Handles OR sequences. 33 | Parser or() { 34 | final g = (ref0(group) | ref0(scopedExclusion) | ref0(exclusion)) & 35 | ((string(' | ') | string(' OR ')) & ref0(root)) 36 | .map((list) => list.last) 37 | .star(); 38 | return g.token().map((list) { 39 | final children = [ 40 | list.value.first as Query, 41 | for (final query in (list.value.last as List).cast()) 42 | // flatten OrQuery children 43 | if (query is OrQuery) 44 | for (final child in query.children) child 45 | else 46 | query, 47 | ]; 48 | if (children.length == 1) return children.single; 49 | return OrQuery( 50 | children: children, position: SourcePosition(list.start, list.stop)); 51 | }); 52 | } 53 | 54 | Parser exclusionSep() => char('-') | (string('NOT') & ref0(EXP_SEP)); 55 | 56 | // Handles scope: 57 | Parser scopedExpression() { 58 | final g = (anyCharExcept(':').flatten().textQuery() & char(':')) & 59 | ref0(exclusion).orEmptyTextQuery(); 60 | return g.token().map((list) => list.value.first == null 61 | ? list.value.last as Query 62 | : ScopeQuery( 63 | field: list.value.first as TextQuery, 64 | child: list.value.last as Query, 65 | position: SourcePosition(list.start, list.stop))); 66 | } 67 | 68 | // Handles -scope: 69 | Parser scopedExclusion() { 70 | final g = exclusionSep().optional() & ref0(scopedExpression); 71 | return g.token().map((list) => list.value.first == null 72 | ? list.value.last as Query 73 | : NotQuery( 74 | child: list.value.last as Query, 75 | position: SourcePosition(list.start, list.stop))); 76 | } 77 | 78 | // Handles - 79 | Parser exclusion() { 80 | final g = exclusionSep().optional() & ref0(expression); 81 | return g.token().map((list) => list.value.first == null 82 | ? list.value.last as Query 83 | : NotQuery( 84 | child: list.value.last as Query, 85 | position: SourcePosition(list.start, list.stop))); 86 | } 87 | 88 | Parser expression() => 89 | ref0(group) | ref0(exact) | ref0(range) | ref0(comparison) | ref0(WORD); 90 | 91 | Parser group() { 92 | final g = char('(') & 93 | ref0(EXP_SEP).star() & 94 | ref0(root).orEmptyTextQuery() & 95 | ref0(EXP_SEP).star() & 96 | char(')'); 97 | return g.token().map((list) => GroupQuery( 98 | child: list.value[2] as Query, 99 | position: SourcePosition(list.start, list.stop))); 100 | } 101 | 102 | Parser comparison() { 103 | final g = ref0(IDENTIFIER).textQuery() & 104 | ref0(EXP_SEP).optional() & 105 | ref0(COMP_OPERATOR).textQuery() & 106 | ref0(EXP_SEP).optional() & 107 | ref0(wordOrExact).orEmptyTextQuery(); 108 | return g.token().map((list) => CompareQuery( 109 | field: list.value[0] as TextQuery, 110 | operator: list.value[2] as TextQuery, 111 | text: list.value[4] as TextQuery, 112 | position: SourcePosition(list.start, list.stop))); 113 | } 114 | 115 | Parser range() { 116 | final g = ref0(rangeSep) & 117 | ref0(wordOrExact) & 118 | string(' TO ') & 119 | ref0(wordOrExact) & 120 | ref0(rangeSep); 121 | return g.token().map((list) { 122 | return RangeQuery( 123 | start: list.value[1] as TextQuery, 124 | end: list.value[3] as TextQuery, 125 | startInclusive: list.value[0] == '[', 126 | endInclusive: list.value[4] == ']', 127 | position: SourcePosition(list.start, list.stop)); 128 | }); 129 | } 130 | 131 | Parser rangeSep() => char('[') | char(']'); 132 | 133 | Parser wordOrExact() => 134 | (ref0(exact) | ref0(WORD)).cast(); 135 | 136 | Parser exactWord() => 137 | pattern('^" \t\n\r').plus().flatten().textQuery(); 138 | 139 | Parser exact() { 140 | final g = char('"') & 141 | ref0(EXP_SEP).star().flatten() & 142 | (ref0(exactWord) & ref0(EXP_SEP).star().flatten()).star() & 143 | char('"'); 144 | return g.token().map((list) { 145 | final children = []; 146 | var phrase = list.value[1] as String; 147 | for (var w in list.value[2]) { 148 | final word = w.first as TextQuery; 149 | final sep = w[1] as String; 150 | children.add(word); 151 | phrase += '${word.text}$sep'; 152 | } 153 | return PhraseQuery( 154 | text: phrase, 155 | children: children, 156 | position: SourcePosition(list.start, list.stop)); 157 | }); 158 | } 159 | 160 | Parser EXP_SEP() => WORD_SEP(); 161 | 162 | Parser WORD_SEP() => whitespace().plus().map((_) => ' '); 163 | 164 | Parser WORD() => allowedChars().plus().flatten().textQuery(); 165 | 166 | Parser IDENTIFIER() => allowedChars().plus().flatten(); 167 | 168 | Parser COMP_OPERATOR() => (string('<=') | 169 | string('<') | 170 | string('>=') | 171 | string('>') | 172 | string('!=') | 173 | string('=')) 174 | .flatten(); 175 | 176 | Parser allowedChars() => anyCharExcept('[]():"'); 177 | } 178 | 179 | Parser extendedWord([String message = 'letter or digit expected']) { 180 | return CharacterParser(const ExtendedWordCharPredicate(), message); 181 | } 182 | 183 | class ExtendedWordCharPredicate implements CharacterPredicate { 184 | const ExtendedWordCharPredicate(); 185 | 186 | @override 187 | bool test(int value) { 188 | return (65 <= value && value <= 90 /* A..Z */) || 189 | (97 <= value && value <= 122 /* a..z */) || 190 | (48 <= value && value <= 57 /* 0..9 */) || 191 | (value == 95 /* _ */) || 192 | (value > 128); 193 | } 194 | 195 | @override 196 | bool isEqualTo(CharacterPredicate other) { 197 | return (other is ExtendedWordCharPredicate); 198 | } 199 | } 200 | 201 | Parser anyCharExcept(String except, 202 | [String message = 'letter or digit expected']) { 203 | return CharacterParser(AnyCharExceptPredicate(except.codeUnits), message) 204 | .plus() 205 | .flatten(); 206 | } 207 | 208 | class AnyCharExceptPredicate implements CharacterPredicate { 209 | final List exceptCodeUnits; 210 | AnyCharExceptPredicate(this.exceptCodeUnits); 211 | static final _ws = WhitespaceCharPredicate(); 212 | 213 | @override 214 | bool test(int value) => !_ws.test(value) && !exceptCodeUnits.contains(value); 215 | 216 | @override 217 | bool isEqualTo(CharacterPredicate other) { 218 | return (other is AnyCharExceptPredicate) && identical(this, other); 219 | } 220 | } 221 | 222 | extension on Parser { 223 | Parser textQuery() => token().map((str) => TextQuery( 224 | text: str.value, position: SourcePosition(str.start, str.stop))); 225 | } 226 | 227 | extension on Parser { 228 | Parser orEmptyTextQuery() => optional().token().map((value) => 229 | value.value ?? 230 | TextQuery(text: '', position: SourcePosition(value.start, value.stop))); 231 | } 232 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: query 2 | description: > 3 | Search query parser to implement customized search. 4 | Supports boolean groups, field scopes, ranges, comparisons... 5 | version: 2.2.0 6 | repository: https://github.com/isoos/query 7 | topics: 8 | - query 9 | - parser 10 | - query-parser 11 | 12 | environment: 13 | sdk: '>=3.0.0 <4.0.0' 14 | 15 | dependencies: 16 | petitparser: ^6.0.0 17 | 18 | dev_dependencies: 19 | lints: ^4.0.0 20 | test: ^1.0.0 21 | -------------------------------------------------------------------------------- /test/query_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:query/query.dart'; 2 | import 'package:test/test.dart'; 3 | import 'package:test/test.dart' as t; 4 | 5 | extension on T { 6 | R expect(String matcher, int start, int end) { 7 | t.expect(runtimeType, R); 8 | t.expect(toString(debug: true), matcher); 9 | t.expect(position.start, start); 10 | t.expect(position.end, end); 11 | return cast(); 12 | } 13 | } 14 | 15 | void main() { 16 | group('Base expressions', () { 17 | test('1 string', () { 18 | parseQuery('abc').expect('', 0, 3); 19 | parseQuery('"abc"') 20 | .expect('""', 0, 5) 21 | .children 22 | .first 23 | .expect('', 1, 4); 24 | parseQuery('abc*').expect('', 0, 4); 25 | }); 26 | 27 | test('2 strings', () { 28 | parseQuery('abc def').expect('( )', 0, 7) 29 | ..children[0].expect('', 0, 3) 30 | ..children[1].expect('', 4, 7); 31 | parseQuery('"abc 1" def').expect('(" <1>" )', 0, 11) 32 | ..children[0].expect('" <1>"', 0, 7) 33 | ..children[1].expect('', 8, 11); 34 | parseQuery('abc "def 2"').expect('( " <2>")', 0, 11); 35 | parseQuery('"abc" "def"').expect('("" "")', 0, 11); 36 | parseQuery('"abc 1" "def 2"') 37 | .expect('(" <1>" " <2>")', 0, 15); 38 | }); 39 | 40 | test('explicit AND', () { 41 | parseQuery('a AND b c').expect('( )', 0, 9) 42 | ..children[0].expect('', 0, 1) 43 | ..children[1].expect('', 6, 7) 44 | ..children[2].expect('', 8, 9); 45 | }); 46 | 47 | test('negative word', () { 48 | parseQuery('-abc') 49 | .expect('-', 0, 4) 50 | .child 51 | .expect('', 1, 4); 52 | parseQuery('-"abc"') 53 | .expect('-""', 0, 6) 54 | .child 55 | .expect('""', 1, 6); 56 | parseQuery('-"abc 1"') 57 | .expect('-" <1>"', 0, 8) 58 | .child 59 | .expect('" <1>"', 1, 8) 60 | .children 61 | .first 62 | .expect('', 2, 5); 63 | parseQuery('NOT abc') 64 | .expect('-', 0, 7) 65 | .child 66 | .expect('', 4, 7); 67 | parseQuery('NOT "abc"').expect('-""', 0, 9); 68 | parseQuery('NOT "abc 1"').expect('-" <1>"', 0, 11); 69 | }); 70 | 71 | test('scoped', () { 72 | parseQuery('a:abc').expect('a:', 0, 5) 73 | ..field.expect('', 0, 1) 74 | ..child.expect('', 2, 5); 75 | parseQuery('a:"abc"').expect('a:""', 0, 7); 76 | parseQuery('a:"abc 1"').expect('a:" <1>"', 0, 9); 77 | parseQuery('a:-"abc 1"').expect('a:-" <1>"', 0, 10); 78 | parseQuery('NOT field:abc') 79 | .expect('-field:', 0, 13) 80 | .child 81 | .expect('field:', 4, 13) 82 | ..field.expect('', 4, 9) 83 | ..child.expect('', 10, 13); 84 | parseQuery('a:').expect('a:<>', 0, 2); 85 | parseQuery('a: AND a').expect('(a:<> )', 0, 8); 86 | }); 87 | 88 | test('special scoped', () { 89 | parseQuery('a*:abc').expect('a*:', 0, 6); 90 | parseQuery('a%:"abc"').expect('a%:""', 0, 8); 91 | }); 92 | 93 | test('compare', () { 94 | parseQuery('year < 2000').expect('', 0, 11) 95 | ..field.expect('', 0, 4) 96 | ..operator.expect('<<>', 5, 6) 97 | ..text.expect('<2000>', 7, 11); 98 | parseQuery('field >= "test case"') 99 | .expect('="test case">', 0, 20) 100 | ..field.expect('', 0, 5) 101 | ..operator.expect('<>=>', 6, 8) 102 | ..text.expect('" "', 9, 20); 103 | parseQuery('year = ').expect('', 0, 7); 104 | }); 105 | 106 | test('range', () { 107 | parseQuery('[1 TO 10]').expect('<[<1> TO <10>]>', 0, 9) 108 | ..start.expect('<1>', 1, 2) 109 | ..end.expect('<10>', 6, 8); 110 | parseQuery(']1 TO 10[').expect('<]<1> TO <10>[>', 0, 9) 111 | ..start.expect('<1>', 1, 2) 112 | ..end.expect('<10>', 6, 8); 113 | parseQuery(']"1 a" TO "10 b"[') 114 | .expect('<]"<1> " TO "<10> "[>', 0, 17) 115 | ..start.expect('"<1> "', 1, 6) 116 | ..end.expect('"<10> "', 10, 16); 117 | }); 118 | }); 119 | 120 | group('or', () { 121 | test('2 items', () { 122 | parseQuery('a OR b').expect('( OR )', 0, 6) 123 | ..children.first.expect('', 0, 1) 124 | ..children.last.expect('', 5, 6); 125 | }); 126 | 127 | test('2 items pipe', () { 128 | parseQuery('a | b').expect('( OR )', 0, 5) 129 | ..children.first.expect('', 0, 1) 130 | ..children.last.expect('', 4, 5); 131 | }); 132 | 133 | test('3 items', () { 134 | parseQuery('a OR b OR c').expect('( OR OR )', 0, 11) 135 | ..children[0].expect('', 0, 1) 136 | ..children[1].expect('', 5, 6) 137 | ..children[2].expect('', 10, 11); 138 | }); 139 | 140 | test('3 items pipe', () { 141 | parseQuery('a | b | c').expect('( OR OR )', 0, 9) 142 | ..children[0].expect('', 0, 1) 143 | ..children[1].expect('', 4, 5) 144 | ..children[2].expect('', 8, 9); 145 | }); 146 | 147 | test('3 items pipe mixed', () { 148 | parseQuery('a OR b | c').expect('( OR OR )', 0, 10) 149 | ..children[0].expect('', 0, 1) 150 | ..children[1].expect('', 5, 6) 151 | ..children[2].expect('', 9, 10); 152 | parseQuery('a | b OR c').expect('( OR OR )', 0, 10) 153 | ..children[0].expect('', 0, 1) 154 | ..children[1].expect('', 4, 5) 155 | ..children[2].expect('', 9, 10); 156 | }); 157 | 158 | test('precedence of implicit AND, explicit OR', () { 159 | parseQuery('a b OR c').expect('( ( OR ))', 0, 8); 160 | parseQuery('a OR b c').expect('( OR ( ))', 0, 8); 161 | parseQuery('a OR b c OR d') 162 | .expect('( OR ( ( OR )))', 0, 13); 163 | }); 164 | 165 | test('precedence of implicit AND, explicit OR pipe', () { 166 | parseQuery('a b | c').expect('( ( OR ))', 0, 7); 167 | parseQuery('a | b c').expect('( OR ( ))', 0, 7); 168 | parseQuery('a | b c | d') 169 | .expect('( OR ( ( OR )))', 0, 11); 170 | }); 171 | }); 172 | 173 | group('complex cases', () { 174 | test('#1', () { 175 | parseQuery('a:-v1 b:(beta OR moon < Deimos OR [a TO e])') 176 | .expect( 177 | '(a:- b:(( OR OR <[ TO ]>)))', 178 | 0, 179 | 43); 180 | }); 181 | 182 | test('#2', () { 183 | parseQuery('a = 2000 b > 2000 c') 184 | .expect('( 2000> )', 0, 19); 185 | }); 186 | 187 | test('#3', () { 188 | parseQuery('(f:abc)').expect('(f:)', 0, 7); 189 | }); 190 | }); 191 | 192 | group('unicode chars', () { 193 | test('hungarian', () { 194 | parseQuery('árvíztűrő TÜKÖRFÚRÓGÉP') 195 | .expect('(<árvíztűrő> )', 0, 22); 196 | }); 197 | }); 198 | 199 | group('grouping precedence', () { 200 | test('empty group', () { 201 | parseQuery('()').expect('(<>)', 0, 2); 202 | }); 203 | test('empty group with space', () { 204 | parseQuery('( )').expect('(<>)', 0, 4); 205 | }); 206 | test('single item group', () { 207 | parseQuery('(a)').expect('()', 0, 3); 208 | }); 209 | test('single item group with space', () { 210 | parseQuery('( a )').expect('()', 0, 5); 211 | }); 212 | test('grouping with two items implicit AND', () { 213 | parseQuery('(a b)').expect('(( ))', 0, 5); 214 | }); 215 | test('grouping with two items explicit AND', () { 216 | parseQuery('(a AND b)').expect('(( ))', 0, 9); 217 | }); 218 | test('grouping with multiple items', () { 219 | parseQuery('(a | b) c (d | e)') 220 | .expect('((( OR )) (( OR )))', 0, 17); 221 | }); 222 | test('nested grouping', () { 223 | parseQuery('(a OR b) OR c') 224 | .expect('((( OR )) OR )', 0, 13); 225 | parseQuery('(a OR b) c').expect('((( OR )) )', 0, 10); 226 | parseQuery('((a OR b) c) | d') 227 | .expect('((((( OR )) )) OR )', 0, 16); 228 | }); 229 | test('negative grouping', () { 230 | parseQuery('-(a OR b) OR c') 231 | .expect('(-(( OR )) OR )', 0, 14); 232 | parseQuery('(a OR -b) c') 233 | .expect('((( OR -)) )', 0, 11); 234 | parseQuery('-(-(a OR -b) -c) | -(d)') 235 | .expect('(-((-(( OR -)) -)) OR -())', 0, 23); 236 | }); 237 | test('scoped grouping', () { 238 | parseQuery('(field:abc)').expect('(field:)', 0, 11); 239 | parseQuery('(field:abc AND field:def)') 240 | .expect('((field: field:))', 0, 25); 241 | parseQuery('(field:abc OR field:def)') 242 | .expect('((field: OR field:))', 0, 24); 243 | }); 244 | }); 245 | 246 | group('phrase match', () { 247 | test('empty phrase', () { 248 | parseQuery('""').expect('""', 0, 2); 249 | }); 250 | test('empty phrase with space', () { 251 | parseQuery('" "').expect('""', 0, 4); 252 | }); 253 | test('simple word phrase', () { 254 | parseQuery('"a"').expect('""', 0, 3); 255 | }); 256 | test('single word phrase with space', () { 257 | parseQuery('" a "').expect('""', 0, 5); 258 | }); 259 | test('two word phrase', () { 260 | parseQuery('"a b"').expect('" "', 0, 5); 261 | }); 262 | test('three word phrase', () { 263 | parseQuery('"a b c"').expect('" "', 0, 7); 264 | }); 265 | test('three word phrase with AND', () { 266 | parseQuery('"a AND b"').expect('" "', 0, 9); 267 | }); 268 | test('three word phrase with OR', () { 269 | parseQuery('"a OR b"').expect('" "', 0, 8); 270 | }); 271 | test('negative phrase grouping', () { 272 | parseQuery('-("a OR b") OR c') 273 | .expect('(-(" ") OR )', 0, 16); 274 | parseQuery('(a OR -"b") ("c")') 275 | .expect('((( OR -"")) (""))', 0, 17); 276 | parseQuery('-(-"a OR -b" -c) | -"d"') 277 | .expect('(-((-" <-b>" -)) OR -"")', 0, 23); 278 | }); 279 | test('phrase with parenthesis', () { 280 | parseQuery('"(a OR -b)" -("-c | []")') 281 | .expect('("<(a> <-b)>" -("<-c> <|> <[]>"))', 0, 24); 282 | }); 283 | }); 284 | } 285 | --------------------------------------------------------------------------------