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?# => trueresult.query# => JqlRuby::Ast::Queryresult.query.where_clause# => JqlRuby::Ast::AndClauseresult.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# => AndClauseclause.clauses[0].field.name# => "priority"clause.clauses[0].operator.value# => :eqclause.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_keyconfig.field"votes"config.field"created",column: :created_atconfig.field"duedate",column: :due_date# custom resolver for fields that need joins or complex logicconfig.field"assignee"do |scope,operator,value|
caseoperatorwhen:eqscope.joins(:assignee).where(users: {username: value})when:isscope.where(assignee_id: nil)when:is_notscope.where.not(assignee_id: nil)elseraiseJqlRuby::UnsupportedOperatorError,"assignee does not support #{operator}"endend# functions resolve to a value at query timeconfig.function"currentUser"do |context|
context[:current_user].usernameendconfig.function"now"do |_context|
Time.currentendendresult=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:
classMySequelAdapter < JqlRuby::Adapters::Baseprotecteddefbuild_scope(model)# return initial dataset/scopeenddefapply_and(scope,scopes)# combine scopes with ANDenddefapply_or(scope,scopes)# combine scopes with ORenddefapply_not(scope,inner_scope)# negate a scopeenddefapply_terminal(scope,field_def,operator,value)# apply a single field comparison# field_def.column gives you the mapped column nameenddefapply_order(scope,field_def,direction)# apply ORDER BY (direction is :asc or :desc)endendThe 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?# => falseresult.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.