jql_ruby 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +21 -0
- data/LICENSE +21 -0
- data/README.md +231 -0
- data/lib/jql_ruby/adapters/active_record.rb +75 -0
- data/lib/jql_ruby/adapters/base.rb +154 -0
- data/lib/jql_ruby/adapters/configuration.rb +50 -0
- data/lib/jql_ruby/adapters.rb +5 -0
- data/lib/jql_ruby/ast/compound_clause.rb +31 -0
- data/lib/jql_ruby/ast/field.rb +47 -0
- data/lib/jql_ruby/ast/node.rb +25 -0
- data/lib/jql_ruby/ast/not_clause.rb +19 -0
- data/lib/jql_ruby/ast/operand.rb +58 -0
- data/lib/jql_ruby/ast/operator.rb +28 -0
- data/lib/jql_ruby/ast/order_by.rb +32 -0
- data/lib/jql_ruby/ast/predicate.rb +21 -0
- data/lib/jql_ruby/ast/query.rb +19 -0
- data/lib/jql_ruby/ast/terminal_clause.rb +21 -0
- data/lib/jql_ruby/ast.rb +12 -0
- data/lib/jql_ruby/errors.rb +26 -0
- data/lib/jql_ruby/lexer.rb +245 -0
- data/lib/jql_ruby/parser.rb +576 -0
- data/lib/jql_ruby/result.rb +16 -0
- data/lib/jql_ruby/token.rb +66 -0
- data/lib/jql_ruby/version.rb +5 -0
- data/lib/jql_ruby.rb +19 -0
- metadata +125 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5a08c2507174f677a6b35095285197a2a58f1114946e86b68a7a440a95bbc1bc
|
|
4
|
+
data.tar.gz: cf908b48b100e8336c598fa5b8dd42ba4c506395dd6b45cbe3b46af07f61953c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 53de7669ff8f13e8191341a2754da46d31af20510a81158e68840b5162a71c25e0c3ae1ef2cb112fbf98532fc3f3be7f21780f2a7ee82574ba15839cc120ab12
|
|
7
|
+
data.tar.gz: 50f81f5ef39112880e2e269930bee53f911e0149b68624c6ea319cf794a44a857b118a741fc47d6a12d4f8a9a68f555350114ca5be332304eebb82fa7c791d4d
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-04-05
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Recursive descent parser for the full JQL grammar (AND, OR, NOT, parentheses, ORDER BY)
|
|
12
|
+
- Terminal clause operators: `=`, `!=`, `<`, `>`, `<=`, `>=`, `~`, `!~`, `IN`, `NOT IN`, `IS`, `IS NOT`, `WAS`, `WAS NOT`, `WAS IN`, `WAS NOT IN`, `CHANGED`
|
|
13
|
+
- WAS/CHANGED predicate support: `AFTER`, `BEFORE`, `DURING`, `ON`, `BY`, `FROM`, `TO`
|
|
14
|
+
- Function operands (e.g. `currentUser()`, `startOfMonth("-1")`)
|
|
15
|
+
- Custom field syntax (`cf[10001]`)
|
|
16
|
+
- Quoted and unquoted string values, numbers
|
|
17
|
+
- AST node classes with visitor-pattern `accept` methods
|
|
18
|
+
- Adapter pattern for ORM integration (`Adapters::Base`)
|
|
19
|
+
- ActiveRecord adapter with Arel-based query building
|
|
20
|
+
- Configurable field mapping (simple column, or custom resolver block)
|
|
21
|
+
- Configurable function resolvers with runtime context
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ignition App Pty Ltd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# JqlRuby
|
|
2
|
+
|
|
3
|
+
A pure-Ruby parser for [Jira Query Language (JQL)](https://support.atlassian.com/jira-software-cloud/docs/use-advanced-search-with-jira-query-language-jql/). Parses JQL strings into an abstract syntax tree and optionally converts them into ORM queries via an adapter pattern.
|
|
4
|
+
|
|
5
|
+
The grammar is modeled after Atlassian's [@atlaskit/jql-parser](https://www.npmjs.com/package/@atlaskit/jql-parser).
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Add to your Gemfile:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem "jql_ruby"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or install directly:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
gem install jql_ruby
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
result = JqlRuby.parse('project = MYPROJ AND status = Open ORDER BY created DESC')
|
|
25
|
+
|
|
26
|
+
result.success? # => true
|
|
27
|
+
result.query # => JqlRuby::Ast::Query
|
|
28
|
+
|
|
29
|
+
result.query.where_clause # => JqlRuby::Ast::AndClause
|
|
30
|
+
result.query.order_by # => JqlRuby::Ast::OrderBy
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Supported Grammar
|
|
34
|
+
|
|
35
|
+
JqlRuby supports the full JQL specification:
|
|
36
|
+
|
|
37
|
+
| Category | Syntax |
|
|
38
|
+
|---|---|
|
|
39
|
+
| **Equality** | `=`, `!=` |
|
|
40
|
+
| **Comparison** | `<`, `>`, `<=`, `>=` |
|
|
41
|
+
| **Contains** | `~` (LIKE), `!~` (NOT LIKE) |
|
|
42
|
+
| **Set** | `IN (...)`, `NOT IN (...)` |
|
|
43
|
+
| **Null checks** | `IS EMPTY`, `IS NOT EMPTY`, `IS NULL`, `IS NOT NULL` |
|
|
44
|
+
| **History** | `WAS`, `WAS NOT`, `WAS IN`, `WAS NOT IN` |
|
|
45
|
+
| **Change** | `CHANGED` |
|
|
46
|
+
| **Predicates** | `AFTER`, `BEFORE`, `DURING`, `ON`, `BY`, `FROM`, `TO` |
|
|
47
|
+
| **Logical** | `AND`, `OR`, `NOT`, `!`, parentheses |
|
|
48
|
+
| **Ordering** | `ORDER BY field ASC/DESC` |
|
|
49
|
+
| **Functions** | `currentUser()`, `now()`, any `name(args...)` |
|
|
50
|
+
| **Custom fields** | `cf[10001]` |
|
|
51
|
+
|
|
52
|
+
## AST Nodes
|
|
53
|
+
|
|
54
|
+
Parsing produces a tree of AST nodes:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
Query
|
|
58
|
+
├── where_clause (one of:)
|
|
59
|
+
│ ├── TerminalClause — field, operator, operand, predicates
|
|
60
|
+
│ ├── AndClause — clauses[]
|
|
61
|
+
│ ├── OrClause — clauses[]
|
|
62
|
+
│ └── NotClause — clause, operator (:not or :bang)
|
|
63
|
+
└── order_by
|
|
64
|
+
└── OrderBy — fields[] of SearchSort (field, direction)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Operand types
|
|
68
|
+
|
|
69
|
+
- `ValueOperand` — string or number literal
|
|
70
|
+
- `FunctionOperand` — function name + arguments
|
|
71
|
+
- `ListOperand` — parenthesized list of operands
|
|
72
|
+
- `KeywordOperand` — `EMPTY` or `NULL`
|
|
73
|
+
|
|
74
|
+
### Working with the AST
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
result = JqlRuby.parse('priority = High AND duedate < now()')
|
|
78
|
+
clause = result.query.where_clause # => AndClause
|
|
79
|
+
|
|
80
|
+
clause.clauses[0].field.name # => "priority"
|
|
81
|
+
clause.clauses[0].operator.value # => :eq
|
|
82
|
+
clause.clauses[0].operand.value # => "High"
|
|
83
|
+
|
|
84
|
+
clause.clauses[1].operand # => FunctionOperand(name: "now")
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Every node has an `accept(visitor)` method for implementing the visitor pattern.
|
|
88
|
+
|
|
89
|
+
## ActiveRecord Adapter
|
|
90
|
+
|
|
91
|
+
The gem includes an adapter that converts parsed JQL into ActiveRecord scopes.
|
|
92
|
+
|
|
93
|
+
### Setup
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
adapter = JqlRuby::Adapters::ActiveRecord.new(Issue) do |config|
|
|
97
|
+
# simple column mapping (field name defaults to column name)
|
|
98
|
+
config.field "status"
|
|
99
|
+
config.field "project", column: :project_key
|
|
100
|
+
config.field "votes"
|
|
101
|
+
config.field "created", column: :created_at
|
|
102
|
+
config.field "duedate", column: :due_date
|
|
103
|
+
|
|
104
|
+
# custom resolver for fields that need joins or complex logic
|
|
105
|
+
config.field "assignee" do |scope, operator, value|
|
|
106
|
+
case operator
|
|
107
|
+
when :eq
|
|
108
|
+
scope.joins(:assignee).where(users: { username: value })
|
|
109
|
+
when :is
|
|
110
|
+
scope.where(assignee_id: nil)
|
|
111
|
+
when :is_not
|
|
112
|
+
scope.where.not(assignee_id: nil)
|
|
113
|
+
else
|
|
114
|
+
raise JqlRuby::UnsupportedOperatorError, "assignee does not support #{operator}"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# functions resolve to a value at query time
|
|
119
|
+
config.function "currentUser" do |context|
|
|
120
|
+
context[:current_user].username
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
config.function "now" do |_context|
|
|
124
|
+
Time.current
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Querying
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
result = JqlRuby.parse('project = FOO AND status IN (Open, "In Progress") ORDER BY created DESC')
|
|
133
|
+
scope = adapter.apply(result.query, context: { current_user: current_user })
|
|
134
|
+
# => Issue.where(project_key: "FOO").where(status: ["Open", "In Progress"]).order(created_at: :desc)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Operator mapping
|
|
138
|
+
|
|
139
|
+
| JQL operator | Arel method |
|
|
140
|
+
|---|---|
|
|
141
|
+
| `=` | `.eq` |
|
|
142
|
+
| `!=` | `.not_eq` |
|
|
143
|
+
| `<` / `>` / `<=` / `>=` | `.lt` / `.gt` / `.lteq` / `.gteq` |
|
|
144
|
+
| `~` | `.matches` (wraps with `%`) |
|
|
145
|
+
| `!~` | `.does_not_match` |
|
|
146
|
+
| `IN` | `.in` |
|
|
147
|
+
| `NOT IN` | `.not_in` |
|
|
148
|
+
| `IS EMPTY/NULL` | `.eq(nil)` |
|
|
149
|
+
| `IS NOT EMPTY/NULL` | `.not_eq(nil)` |
|
|
150
|
+
|
|
151
|
+
`WAS`, `WAS NOT`, `WAS IN`, `WAS NOT IN`, and `CHANGED` raise `UnsupportedOperatorError` since they require history tables. Use a custom field resolver block to handle these for your schema.
|
|
152
|
+
|
|
153
|
+
## Building Custom Adapters
|
|
154
|
+
|
|
155
|
+
The adapter pattern is designed for extension. Subclass `JqlRuby::Adapters::Base` and implement six hooks:
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
class MySequelAdapter < JqlRuby::Adapters::Base
|
|
159
|
+
protected
|
|
160
|
+
|
|
161
|
+
def build_scope(model)
|
|
162
|
+
# return initial dataset/scope
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def apply_and(scope, scopes)
|
|
166
|
+
# combine scopes with AND
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def apply_or(scope, scopes)
|
|
170
|
+
# combine scopes with OR
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def apply_not(scope, inner_scope)
|
|
174
|
+
# negate a scope
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def apply_terminal(scope, field_def, operator, value)
|
|
178
|
+
# apply a single field comparison
|
|
179
|
+
# field_def.column gives you the mapped column name
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def apply_order(scope, field_def, direction)
|
|
183
|
+
# apply ORDER BY (direction is :asc or :desc)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
The base class handles AST traversal, field/function resolution, and operand extraction. Your adapter only needs to translate those into ORM-specific calls.
|
|
189
|
+
|
|
190
|
+
## Error Handling
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
result = JqlRuby.parse("invalid = = query")
|
|
194
|
+
result.success? # => false
|
|
195
|
+
result.errors # => [#<JqlRuby::ParseError ...>]
|
|
196
|
+
result.errors.first.message # => "expected value at position 10"
|
|
197
|
+
result.errors.first.position # => 10
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Error classes
|
|
201
|
+
|
|
202
|
+
| Class | Raised when |
|
|
203
|
+
|---|---|
|
|
204
|
+
| `JqlRuby::ParseError` | JQL syntax is invalid |
|
|
205
|
+
| `JqlRuby::LexerError` | Tokenization fails (e.g. unterminated string) |
|
|
206
|
+
| `JqlRuby::UnknownFieldError` | Adapter encounters an unmapped field |
|
|
207
|
+
| `JqlRuby::UnknownFunctionError` | Adapter encounters an unmapped function |
|
|
208
|
+
| `JqlRuby::UnsupportedOperatorError` | Adapter encounters an operator it can't handle |
|
|
209
|
+
|
|
210
|
+
## Development
|
|
211
|
+
|
|
212
|
+
```
|
|
213
|
+
git clone https://github.com/ignitionapp/jql_ruby.git
|
|
214
|
+
cd jql_ruby
|
|
215
|
+
bundle install
|
|
216
|
+
bundle exec rake spec
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## Contributing
|
|
220
|
+
|
|
221
|
+
Bug reports and pull requests are welcome on [GitHub](https://github.com/ignitionapp/jql_ruby).
|
|
222
|
+
|
|
223
|
+
1. Fork the repo
|
|
224
|
+
2. Create your feature branch (`git checkout -b my-feature`)
|
|
225
|
+
3. Add tests for your changes
|
|
226
|
+
4. Make sure all tests pass (`bundle exec rake spec`)
|
|
227
|
+
5. Commit and open a pull request
|
|
228
|
+
|
|
229
|
+
## License
|
|
230
|
+
|
|
231
|
+
Released under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JqlRuby
|
|
4
|
+
module Adapters
|
|
5
|
+
class ActiveRecord < Base
|
|
6
|
+
protected
|
|
7
|
+
|
|
8
|
+
def build_scope(model)
|
|
9
|
+
model.all
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def apply_and(_scope, scopes)
|
|
13
|
+
scopes.reduce(:merge)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def apply_or(_scope, scopes)
|
|
17
|
+
scopes.reduce(:or)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def apply_not(scope, inner_scope)
|
|
21
|
+
scope.where(inner_scope.where_clause.ast.not)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def apply_terminal(scope, field_def, operator, value)
|
|
25
|
+
column = arel_column(field_def)
|
|
26
|
+
|
|
27
|
+
predicate = case operator
|
|
28
|
+
when :eq
|
|
29
|
+
column.eq(value)
|
|
30
|
+
when :not_eq
|
|
31
|
+
column.not_eq(value)
|
|
32
|
+
when :lt
|
|
33
|
+
column.lt(value)
|
|
34
|
+
when :gt
|
|
35
|
+
column.gt(value)
|
|
36
|
+
when :lteq
|
|
37
|
+
column.lteq(value)
|
|
38
|
+
when :gteq
|
|
39
|
+
column.gteq(value)
|
|
40
|
+
when :like
|
|
41
|
+
column.matches("%#{sanitize_like(value)}%", "\\", true)
|
|
42
|
+
when :not_like
|
|
43
|
+
column.does_not_match("%#{sanitize_like(value)}%", "\\", true)
|
|
44
|
+
when :in
|
|
45
|
+
column.in(value)
|
|
46
|
+
when :not_in
|
|
47
|
+
column.not_in(value)
|
|
48
|
+
when :is
|
|
49
|
+
column.eq(nil)
|
|
50
|
+
when :is_not
|
|
51
|
+
column.not_eq(nil)
|
|
52
|
+
else
|
|
53
|
+
raise UnsupportedOperatorError, "unsupported operator: #{operator.inspect}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
scope.where(predicate)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def apply_order(scope, field_def, direction)
|
|
60
|
+
column = arel_column(field_def)
|
|
61
|
+
scope.order(column.send(direction))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def arel_column(field_def)
|
|
67
|
+
model.arel_table[field_def.column]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def sanitize_like(value)
|
|
71
|
+
value.to_s.gsub("\\", "\\\\\\\\").gsub("%", "\\%").gsub("_", "\\_")
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JqlRuby
|
|
4
|
+
module Adapters
|
|
5
|
+
class Base
|
|
6
|
+
attr_reader :model, :config
|
|
7
|
+
|
|
8
|
+
def initialize(model, &block)
|
|
9
|
+
@model = model
|
|
10
|
+
@config = Configuration.new
|
|
11
|
+
block&.call(@config)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def apply(query, context: {})
|
|
15
|
+
scope = build_scope(model)
|
|
16
|
+
scope = visit_where(scope, query.where_clause, context) if query.where_clause
|
|
17
|
+
scope = visit_order_by(scope, query.order_by, context) if query.order_by
|
|
18
|
+
scope
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
protected
|
|
22
|
+
|
|
23
|
+
# subclasses must override these hooks
|
|
24
|
+
|
|
25
|
+
def build_scope(_model)
|
|
26
|
+
raise NotImplementedError
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def apply_and(_scope, _scopes)
|
|
30
|
+
raise NotImplementedError
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def apply_or(_scope, _scopes)
|
|
34
|
+
raise NotImplementedError
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def apply_not(_scope, _inner_scope)
|
|
38
|
+
raise NotImplementedError
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def apply_terminal(_scope, _field_def, _operator, _value)
|
|
42
|
+
raise NotImplementedError
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def apply_order(_scope, _field_def, _direction)
|
|
46
|
+
raise NotImplementedError
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def visit_where(scope, node, context)
|
|
52
|
+
case node
|
|
53
|
+
when Ast::OrClause
|
|
54
|
+
visit_or(scope, node, context)
|
|
55
|
+
when Ast::AndClause
|
|
56
|
+
visit_and(scope, node, context)
|
|
57
|
+
when Ast::NotClause
|
|
58
|
+
visit_not(scope, node, context)
|
|
59
|
+
when Ast::TerminalClause
|
|
60
|
+
visit_terminal(scope, node, context)
|
|
61
|
+
else
|
|
62
|
+
raise AdapterError, "unexpected AST node: #{node.class}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def visit_or(scope, node, context)
|
|
67
|
+
scopes = node.clauses.map { |clause| visit_where(build_scope(model), clause, context) }
|
|
68
|
+
apply_or(scope, scopes)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def visit_and(scope, node, context)
|
|
72
|
+
scopes = node.clauses.map { |clause| visit_where(build_scope(model), clause, context) }
|
|
73
|
+
apply_and(scope, scopes)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def visit_not(scope, node, context)
|
|
77
|
+
inner = visit_where(build_scope(model), node.clause, context)
|
|
78
|
+
apply_not(scope, inner)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def visit_terminal(scope, node, context)
|
|
82
|
+
field_name = resolve_field_name(node.field)
|
|
83
|
+
field_def = config.resolve_field(field_name)
|
|
84
|
+
operator = node.operator.value
|
|
85
|
+
value = resolve_operand(node.operand, context)
|
|
86
|
+
predicates = resolve_predicates(node.predicates, context)
|
|
87
|
+
|
|
88
|
+
if field_def.custom?
|
|
89
|
+
if field_def.resolver.arity == 4 || field_def.resolver.arity < 0
|
|
90
|
+
field_def.resolver.call(scope, operator, value, predicates)
|
|
91
|
+
else
|
|
92
|
+
field_def.resolver.call(scope, operator, value)
|
|
93
|
+
end
|
|
94
|
+
else
|
|
95
|
+
validate_operator!(operator)
|
|
96
|
+
apply_terminal(scope, field_def, operator, value)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def visit_order_by(scope, node, _context)
|
|
101
|
+
node.fields.each do |sort|
|
|
102
|
+
field_name = resolve_field_name(sort.field)
|
|
103
|
+
field_def = config.resolve_field(field_name)
|
|
104
|
+
direction = sort.direction || :asc
|
|
105
|
+
scope = apply_order(scope, field_def, direction)
|
|
106
|
+
end
|
|
107
|
+
scope
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def resolve_field_name(field)
|
|
111
|
+
case field
|
|
112
|
+
when Ast::Field
|
|
113
|
+
field.name
|
|
114
|
+
when Ast::CustomField
|
|
115
|
+
"cf[#{field.id}]"
|
|
116
|
+
else
|
|
117
|
+
raise AdapterError, "unexpected field type: #{field.class}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def resolve_operand(operand, context)
|
|
122
|
+
case operand
|
|
123
|
+
when nil
|
|
124
|
+
nil
|
|
125
|
+
when Ast::ValueOperand
|
|
126
|
+
operand.value
|
|
127
|
+
when Ast::KeywordOperand
|
|
128
|
+
nil
|
|
129
|
+
when Ast::FunctionOperand
|
|
130
|
+
config.resolve_function(operand.name, context)
|
|
131
|
+
when Ast::ListOperand
|
|
132
|
+
operand.values.map { |v| resolve_operand(v, context) }
|
|
133
|
+
else
|
|
134
|
+
raise AdapterError, "unexpected operand type: #{operand.class}"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def resolve_predicates(predicates, context)
|
|
139
|
+
predicates.map do |pred|
|
|
140
|
+
{ operator: pred.operator, operand: resolve_operand(pred.operand, context) }
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
UNSUPPORTED_OPERATORS = %i[was was_not was_in was_not_in changed].freeze
|
|
145
|
+
|
|
146
|
+
def validate_operator!(operator)
|
|
147
|
+
if UNSUPPORTED_OPERATORS.include?(operator)
|
|
148
|
+
raise UnsupportedOperatorError,
|
|
149
|
+
"operator #{operator.inspect} requires a custom field resolver (use a block)"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JqlRuby
|
|
4
|
+
module Adapters
|
|
5
|
+
FieldDefinition = Struct.new(:name, :column, :resolver, keyword_init: true) do
|
|
6
|
+
def custom?
|
|
7
|
+
!resolver.nil?
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class Configuration
|
|
12
|
+
attr_reader :field_definitions, :function_resolvers
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@field_definitions = {}
|
|
16
|
+
@function_resolvers = {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def field(name, column: nil, &block)
|
|
20
|
+
normalized = name.to_s.downcase
|
|
21
|
+
col = column || name.to_sym
|
|
22
|
+
@field_definitions[normalized] = FieldDefinition.new(
|
|
23
|
+
name: normalized,
|
|
24
|
+
column: col,
|
|
25
|
+
resolver: block
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def function(name, &block)
|
|
30
|
+
normalized = name.to_s.downcase
|
|
31
|
+
@function_resolvers[normalized] = block
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def resolve_field(name)
|
|
35
|
+
normalized = name.to_s.downcase
|
|
36
|
+
@field_definitions.fetch(normalized) do
|
|
37
|
+
raise UnknownFieldError, "unknown JQL field: #{name.inspect}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def resolve_function(name, context)
|
|
42
|
+
normalized = name.to_s.downcase
|
|
43
|
+
resolver = @function_resolvers.fetch(normalized) do
|
|
44
|
+
raise UnknownFunctionError, "unknown JQL function: #{name.inspect}"
|
|
45
|
+
end
|
|
46
|
+
resolver.call(context)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JqlRuby
|
|
4
|
+
module Ast
|
|
5
|
+
class OrClause < Node
|
|
6
|
+
attr_reader :clauses
|
|
7
|
+
|
|
8
|
+
def initialize(clauses:, **rest)
|
|
9
|
+
super(**rest)
|
|
10
|
+
@clauses = clauses
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def accept(visitor)
|
|
14
|
+
visitor.visit_or_clause(self)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class AndClause < Node
|
|
19
|
+
attr_reader :clauses
|
|
20
|
+
|
|
21
|
+
def initialize(clauses:, **rest)
|
|
22
|
+
super(**rest)
|
|
23
|
+
@clauses = clauses
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def accept(visitor)
|
|
27
|
+
visitor.visit_and_clause(self)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JqlRuby
|
|
4
|
+
module Ast
|
|
5
|
+
class Field < Node
|
|
6
|
+
attr_reader :name, :properties
|
|
7
|
+
|
|
8
|
+
def initialize(name:, properties: [], **rest)
|
|
9
|
+
super(**rest)
|
|
10
|
+
@name = name
|
|
11
|
+
@properties = properties
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def accept(visitor)
|
|
15
|
+
visitor.visit_field(self)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class CustomField < Node
|
|
20
|
+
attr_reader :id, :properties
|
|
21
|
+
|
|
22
|
+
def initialize(id:, properties: [], **rest)
|
|
23
|
+
super(**rest)
|
|
24
|
+
@id = id
|
|
25
|
+
@properties = properties
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def accept(visitor)
|
|
29
|
+
visitor.visit_custom_field(self)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class FieldProperty < Node
|
|
34
|
+
attr_reader :keys, :argument
|
|
35
|
+
|
|
36
|
+
def initialize(keys:, argument: nil, **rest)
|
|
37
|
+
super(**rest)
|
|
38
|
+
@keys = keys
|
|
39
|
+
@argument = argument
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def accept(visitor)
|
|
43
|
+
visitor.visit_field_property(self)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JqlRuby
|
|
4
|
+
module Ast
|
|
5
|
+
class Node
|
|
6
|
+
attr_reader :position
|
|
7
|
+
|
|
8
|
+
def initialize(position: nil)
|
|
9
|
+
@position = position
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def accept(visitor)
|
|
13
|
+
raise NotImplementedError
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def ==(other)
|
|
17
|
+
other.is_a?(self.class) && instance_variables.all? do |ivar|
|
|
18
|
+
instance_variable_get(ivar) == other.instance_variable_get(ivar)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
alias_method :eql?, :==
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JqlRuby
|
|
4
|
+
module Ast
|
|
5
|
+
class NotClause < Node
|
|
6
|
+
attr_reader :clause, :operator
|
|
7
|
+
|
|
8
|
+
def initialize(clause:, operator: :not, **rest)
|
|
9
|
+
super(**rest)
|
|
10
|
+
@clause = clause
|
|
11
|
+
@operator = operator
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def accept(visitor)
|
|
15
|
+
visitor.visit_not_clause(self)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|