JqlRuby
A pure-Ruby parser for Jira Query Language (JQL). Parses JQL strings into an abstract syntax tree and optionally converts them into ORM queries via an adapter pattern.
The grammar is modeled after Atlassian's @atlaskit/jql-parser.
Installation
Add to your Gemfile:
gem "jql_ruby"Or install directly:
gem install jql_ruby
Quick Start
result = JqlRuby.parse('project = MYPROJ AND status = Open ORDER BY created DESC')
result.success? # => true
result.query # => JqlRuby::Ast::Query
result.query.where_clause # => JqlRuby::Ast::AndClause
result.query.order_by # => JqlRuby::Ast::OrderBySupported Grammar
JqlRuby supports the full JQL specification:
| Category | Syntax |
|---|---|
| Equality |
=, !=
|
| Comparison |
<, >, <=, >=
|
| Contains |
~ (LIKE), !~ (NOT LIKE) |
| Set |
IN (...), NOT IN (...)
|
| Null checks |
IS EMPTY, IS NOT EMPTY, IS NULL, IS NOT NULL
|
| History |
WAS, WAS NOT, WAS IN, WAS NOT IN
|
| Change | CHANGED |
| Predicates |
AFTER, BEFORE, DURING, ON, BY, FROM, TO
|
| Logical |
AND, OR, NOT, !, parentheses |
| Ordering | ORDER BY field ASC/DESC |
| Functions |
currentUser(), now(), any name(args...)
|
| Custom fields | cf[10001] |
AST Nodes
Parsing produces a tree of AST nodes:
Query
├── where_clause (one of:)
│ ├── TerminalClause — field, operator, operand, predicates
│ ├── AndClause — clauses[]
│ ├── OrClause — clauses[]
│ └── NotClause — clause, operator (:not or :bang)
└── order_by
└── OrderBy — fields[] of SearchSort (field, direction)
Operand types
-
ValueOperand— string or number literal -
FunctionOperand— function name + arguments -
ListOperand— parenthesized list of operands -
KeywordOperand—EMPTYorNULL
Working with the AST
result = JqlRuby.parse('priority = High AND duedate < now()')
clause = result.query.where_clause # => AndClause
clause.clauses[0].field.name # => "priority"
clause.clauses[0].operator.value # => :eq
clause.clauses[0].operand.value # => "High"
clause.clauses[1].operand # => FunctionOperand(name: "now")Every node has an accept(visitor) method for implementing the visitor pattern.
ActiveRecord Adapter
The gem includes an adapter that converts parsed JQL into ActiveRecord scopes.
Setup
adapter = JqlRuby::Adapters::ActiveRecord.new(Issue) do |config|
# simple column mapping (field name defaults to column name)
config.field "status"
config.field "project", column: :project_key
config.field "votes"
config.field "created", column: :created_at
config.field "duedate", column: :due_date
# custom resolver for fields that need joins or complex logic
config.field "assignee" do |scope, operator, value|
case operator
when :eq
scope.joins(:assignee).where(users: { username: value })
when :is
scope.where(assignee_id: nil)
when :is_not
scope.where.not(assignee_id: nil)
else
raise JqlRuby::UnsupportedOperatorError, "assignee does not support #{operator}"
end
end
# functions resolve to a value at query time
config.function "currentUser" do |context|
context[:current_user].username
end
config.function "now" do |_context|
Time.current
end
endQuerying
result = JqlRuby.parse('project = FOO AND status IN (Open, "In Progress") ORDER BY created DESC')
scope = adapter.apply(result.query, context: { current_user: current_user })
# => Issue.where(project_key: "FOO").where(status: ["Open", "In Progress"]).order(created_at: :desc)Operator mapping
| JQL operator | Arel method |
|---|---|
= |
.eq |
!= |
.not_eq |
< / > / <= / >=
|
.lt / .gt / .lteq / .gteq
|
~ |
.matches (wraps with %) |
!~ |
.does_not_match |
IN |
.in |
NOT IN |
.not_in |
IS EMPTY/NULL |
.eq(nil) |
IS NOT EMPTY/NULL |
.not_eq(nil) |
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.
Building Custom Adapters
The adapter pattern is designed for extension. Subclass JqlRuby::Adapters::Base and implement six hooks:
class MySequelAdapter < JqlRuby::Adapters::Base
protected
def build_scope(model)
# return initial dataset/scope
end
def apply_and(scope, scopes)
# combine scopes with AND
end
def apply_or(scope, scopes)
# combine scopes with OR
end
def apply_not(scope, inner_scope)
# negate a scope
end
def apply_terminal(scope, field_def, operator, value)
# apply a single field comparison
# field_def.column gives you the mapped column name
end
def apply_order(scope, field_def, direction)
# apply ORDER BY (direction is :asc or :desc)
end
endThe base class handles AST traversal, field/function resolution, and operand extraction. Your adapter only needs to translate those into ORM-specific calls.
Error Handling
result = JqlRuby.parse("invalid = = query")
result.success? # => false
result.errors # => [#<JqlRuby::ParseError ...>]
result.errors.first.message # => "expected value at position 10"
result.errors.first.position # => 10Error classes
| Class | Raised when |
|---|---|
JqlRuby::ParseError |
JQL syntax is invalid |
JqlRuby::LexerError |
Tokenization fails (e.g. unterminated string) |
JqlRuby::UnknownFieldError |
Adapter encounters an unmapped field |
JqlRuby::UnknownFunctionError |
Adapter encounters an unmapped function |
JqlRuby::UnsupportedOperatorError |
Adapter encounters an operator it can't handle |
Development
git clone https://github.com/ignitionapp/jql_ruby.git
cd jql_ruby
bundle install
bundle exec rake spec
Contributing
Bug reports and pull requests are welcome on GitHub.
- Fork the repo
- Create your feature branch (
git checkout -b my-feature) - Add tests for your changes
- Make sure all tests pass (
bundle exec rake spec) - Commit and open a pull request
License
Released under the MIT License.