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.
Add to your Gemfile:
gem "jql_ruby"Or install directly:
gem install jql_ruby
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::OrderByJqlRuby 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] |
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)
ValueOperand— string or number literalFunctionOperand— function name + argumentsListOperand— parenthesized list of operandsKeywordOperand—EMPTYorNULL
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.
The gem includes an adapter that converts parsed JQL into ActiveRecord scopes.
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
endresult = 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)| 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.
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.
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 # => 10| 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 |
git clone https://github.com/ignitionapp/jql_ruby.git
cd jql_ruby
bundle install
bundle exec rake spec
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
Released under the MIT License.