https://github.com/ekzo-dev/ruby-rsql
RSQL/FIQL parser for Ruby
https://github.com/ekzo-dev/ruby-rsql
Last synced: 20 days ago
JSON representation
RSQL/FIQL parser for Ruby
- Host: GitHub
- URL: https://github.com/ekzo-dev/ruby-rsql
- Owner: ekzo-dev
- License: mit
- Created: 2026-03-27T08:25:24.000Z (4 months ago)
- Default Branch: main
- Last Pushed: 2026-03-27T08:55:54.000Z (4 months ago)
- Last Synced: 2026-06-16T20:06:32.205Z (about 1 month ago)
- Language: Ruby
- Size: 11.7 KB
- Stars: 1
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- License: LICENSE
Awesome Lists containing this project
README
# rsql_parser
A Ruby parser library for **RSQL** and **FIQL** query expressions. Parses query strings into structured Ruby hashes that can be used to build database queries, filter collections, or power search APIs.
- **FIQL** (Feed Item Query Language): [RFC draft](https://datatracker.ietf.org/doc/html/draft-nottingham-atompub-fiql-00)
- **RSQL**: a superset of FIQL with additional convenience syntax
## Installation
Add to your `Gemfile`:
```ruby
gem 'rsql_parser'
```
Or install directly:
```bash
gem install rsql_parser
```
## Quick Start
```ruby
require 'rsql_parser'
result = RsqlParser.parse('name=="Kill Bill";year=gt=2003')
# => {
# type: :COMBINATION,
# operator: :AND,
# lhs: { type: :CONSTRAINT, selector: "name", comparison: "==", argument: "Kill Bill" },
# rhs: { type: :CONSTRAINT, selector: "year", comparison: "=gt=", argument: "2003" }
# }
```
## Return Value Structure
Every call to `RsqlParser.parse` returns a **node hash**. There are two node types:
### `:CONSTRAINT` — a single condition
| Key | Type | Description |
|--------------|----------|------------------------------------|
| `:type` | Symbol | Always `:CONSTRAINT` |
| `:selector` | String | The field/attribute name |
| `:comparison`| String | The comparison operator |
| `:argument` | String or Array | The value(s) to compare against |
```ruby
RsqlParser.parse('year==2003')
# => { type: :CONSTRAINT, selector: "year", comparison: "==", argument: "2003" }
```
### `:COMBINATION` — two conditions joined by a logical operator
| Key | Type | Description |
|-------------|--------|-------------------------------------|
| `:type` | Symbol | Always `:COMBINATION` |
| `:operator` | Symbol | `:AND` or `:OR` |
| `:lhs` | Hash | Left-hand side node |
| `:rhs` | Hash | Right-hand side node |
```ruby
RsqlParser.parse('a==1;b==2')
# => {
# type: :COMBINATION,
# operator: :AND,
# lhs: { type: :CONSTRAINT, selector: "a", comparison: "==", argument: "1" },
# rhs: { type: :CONSTRAINT, selector: "b", comparison: "==", argument: "2" }
# }
```
## Syntax Reference
### Selectors
A selector is a field name consisting of unreserved characters: letters, digits, and `-._~:`.
```
name
created_at
user.email
http://schema.org/name
```
### Comparison Operators
#### FIQL / RSQL operators
| Operator | Meaning | Example |
|-----------|--------------------------|----------------------|
| `==` | Equal | `name==Alice` |
| `!=` | Not equal | `status!=inactive` |
| `=gt=` | Greater than | `year=gt=2000` |
| `=gte=` | Greater than or equal | `year=gte=2000` |
| `=lt=` | Less than | `price=lt=100` |
| `=lte=` | Less than or equal | `price=lte=100` |
| `=in=` | In a set | `status=in=(a,b,c)` |
| `=out=` | Not in a set | `status=out=(x,y)` |
| `=custom=`| Any custom operator | `field=op=value` |
Custom FIQL operators follow the pattern `=[a-z!]*=` — any lowercase letters or `!` between two `=` signs.
#### Simplified comparison operators
| Operator | Meaning | Example |
|----------|--------------------------|---------------|
| `>` | Greater than | `year>2000` |
| `>=` | Greater than or equal | `year>=2000` |
| `<` | Less than | `price<100` |
| `<=` | Less than or equal | `price<=100` |
### Logical Operators
Conditions can be combined with AND and OR. AND has higher precedence than OR.
#### Symbol syntax
| Symbol | Operator | Example |
|--------|----------|-------------------|
| `;` | AND | `a==1;b==2` |
| `,` | OR | `a==1,b==2` |
#### Keyword syntax (case-insensitive)
| Keyword | Operator | Example |
|---------------|----------|----------------------|
| `and` / `AND` | AND | `a==1 and b==2` |
| `or` / `OR` | OR | `a==1 or b==2` |
Symbol and keyword syntax can be mixed freely. Whitespace around keywords is ignored.
### Arguments
#### Unquoted values
Sequences of unreserved characters (`[a-zA-Z0-9\-._~:]`):
```
year==2003
status==active
date==2018-09-01T12:14:28Z
```
#### Single-quoted strings
Allows spaces, semicolons, commas, and double quotes inside the value. Use `\'` to include a literal single quote:
```
name=='Kill;"Bill"'
tag=='it\'s fine'
```
#### Double-quoted strings
Allows spaces, semicolons, commas, and single quotes inside the value. Use `\"` to include a literal double quote:
```
name=="Kill Bill"
title=="She said \"hello\""
```
#### Array arguments
A parenthesised, comma-separated list. Used with operators like `=in=`:
```
status=in=(active,pending,review)
name=in=("Kill Bill","Pulp Fiction")
```
The `:argument` key will contain a Ruby `Array` instead of a `String`:
```ruby
RsqlParser.parse('genre=in=(sci-fi,action)')
# => { type: :CONSTRAINT, selector: "genre", comparison: "=in=",
# argument: ["sci-fi", "action"] }
```
### Grouping
Parentheses override the default AND-before-OR precedence:
```ruby
# Without grouping: (a AND b) OR c
RsqlParser.parse('a==1;b==2,c==3')
# With grouping: a AND (b OR c)
RsqlParser.parse('a==1;(b==2,c==3)')
```
## Examples
```ruby
require 'rsql_parser'
# Single constraint
RsqlParser.parse('year==2003')
# => { type: :CONSTRAINT, selector: "year", comparison: "==", argument: "2003" }
# Simplified comparison syntax
RsqlParser.parse('price<=99')
# => { type: :CONSTRAINT, selector: "price", comparison: "<=", argument: "99" }
# AND combination (semicolon and keyword are equivalent)
RsqlParser.parse('name=="Kill Bill" and year=gt=2003')
RsqlParser.parse('name=="Kill Bill";year=gt=2003')
# OR combination
RsqlParser.parse('status==active or status==pending')
RsqlParser.parse('status==active,status==pending')
# Array argument
RsqlParser.parse("genre=in=(sci-fi,action);year>2000")
# => {
# type: :COMBINATION,
# operator: :AND,
# lhs: { type: :CONSTRAINT, selector: "genre", comparison: "=in=",
# argument: ["sci-fi", "action"] },
# rhs: { type: :CONSTRAINT, selector: "year", comparison: ">",
# argument: "2000" }
# }
# Chained AND — right-associative tree
RsqlParser.parse('a=eq=b;c=ne=d;e=gt=f')
# => {
# type: :COMBINATION, operator: :AND,
# lhs: { type: :CONSTRAINT, selector: "a", comparison: "=eq=", argument: "b" },
# rhs: {
# type: :COMBINATION, operator: :AND,
# lhs: { type: :CONSTRAINT, selector: "c", comparison: "=ne=", argument: "d" },
# rhs: { type: :CONSTRAINT, selector: "e", comparison: "=gt=", argument: "f" }
# }
# }
# Grouping to change precedence
RsqlParser.parse('a=eq=b;(c=ne=d,e=gt=f)')
# => {
# type: :COMBINATION, operator: :AND,
# lhs: { type: :CONSTRAINT, selector: "a", comparison: "=eq=", argument: "b" },
# rhs: {
# type: :COMBINATION, operator: :OR,
# lhs: { type: :CONSTRAINT, selector: "c", comparison: "=ne=", argument: "d" },
# rhs: { type: :CONSTRAINT, selector: "e", comparison: "=gt=", argument: "f" }
# }
# }
# Escaped quotes inside strings
RsqlParser.parse('title=="She said \"hello\""')
# => { type: :CONSTRAINT, selector: "title", comparison: "==",
# argument: 'She said "hello"' }
```
## Operator Precedence
From highest to lowest:
1. Parentheses `( )`
2. AND — `;` or `and`
3. OR — `,` or `or`
## Requirements
- Ruby >= 2.7.0
- [racc](https://github.com/ruby/racc) ~> 1.8
## Development
```bash
# Run tests
rake test
# Regenerate the lexer after editing lib/rsql_parser/lexer.rex
ruby -roedipus_lex -e "
lex = OedipusLex.new
lex.parse_file('lib/rsql_parser/lexer.rex')
File.write('lib/rsql_parser/lexer.rex.rb', lex.generate)
"
```
Development dependencies: `oedipus_lex ~> 2.6`, `minitest ~> 5.21`, `rake ~> 13.0`.
## Contributing
Open a pull request with your changes and a corresponding test.
## License
MIT © [Ekzo](https://github.com/ekzo-dev)