Project

jql_ruby

0.0
No release in over 3 years
Parses JQL strings into an abstract syntax tree (AST). Modeled after the Atlaskit @atlaskit/jql-parser grammar.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

~> 13.0
~> 3.0
 Project Readme

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::OrderBy

Supported 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
  • KeywordOperandEMPTY or NULL

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
end

Querying

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
end

The 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 # => 10

Error 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.

  1. Fork the repo
  2. Create your feature branch (git checkout -b my-feature)
  3. Add tests for your changes
  4. Make sure all tests pass (bundle exec rake spec)
  5. Commit and open a pull request

License

Released under the MIT License.