rsql_parser 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 716f47ee34f3a7940273ea8e80c7455b9ae13be5c93012f149583768ae00c762
4
+ data.tar.gz: bb977f10808ea4b793a702c0e3cd6298de2c700979fe41b08e52577f541a3dcc
5
+ SHA512:
6
+ metadata.gz: c184118105113d2fe6dbf257498a9ea834b6bb07dee3739e42b3bb25a570e74803a8c462a492ae398fb962c30d6ac173645b333b285098c33e245b0afb45c403
7
+ data.tar.gz: 578825e47740a7e4d731be0f28cc6cfc57cdd0cb18bfacd4f64ec0c29dd464574d8b79f80d027e0c08d9a1d8b24608f4788ae9bc804f1b856313633066e8ed2f
data/CHANGELOG.md ADDED
@@ -0,0 +1,4 @@
1
+ ## [1.0.0] - 2026-03-27
2
+
3
+ * First version
4
+
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in ruby_odata.gemspec
4
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2010 - 2020 Blue Spire Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,289 @@
1
+ # rsql_parser
2
+
3
+ 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.
4
+
5
+ - **FIQL** (Feed Item Query Language): [RFC draft](https://datatracker.ietf.org/doc/html/draft-nottingham-atompub-fiql-00)
6
+ - **RSQL**: a superset of FIQL with additional convenience syntax
7
+
8
+ ## Installation
9
+
10
+ Add to your `Gemfile`:
11
+
12
+ ```ruby
13
+ gem 'rsql_parser'
14
+ ```
15
+
16
+ Or install directly:
17
+
18
+ ```bash
19
+ gem install rsql_parser
20
+ ```
21
+
22
+ ## Quick Start
23
+
24
+ ```ruby
25
+ require 'rsql_parser'
26
+
27
+ result = RsqlParser.parse('name=="Kill Bill";year=gt=2003')
28
+ # => {
29
+ # type: :COMBINATION,
30
+ # operator: :AND,
31
+ # lhs: { type: :CONSTRAINT, selector: "name", comparison: "==", argument: "Kill Bill" },
32
+ # rhs: { type: :CONSTRAINT, selector: "year", comparison: "=gt=", argument: "2003" }
33
+ # }
34
+ ```
35
+
36
+ ## Return Value Structure
37
+
38
+ Every call to `RsqlParser.parse` returns a **node hash**. There are two node types:
39
+
40
+ ### `:CONSTRAINT` — a single condition
41
+
42
+ | Key | Type | Description |
43
+ |--------------|----------|------------------------------------|
44
+ | `:type` | Symbol | Always `:CONSTRAINT` |
45
+ | `:selector` | String | The field/attribute name |
46
+ | `:comparison`| String | The comparison operator |
47
+ | `:argument` | String or Array | The value(s) to compare against |
48
+
49
+ ```ruby
50
+ RsqlParser.parse('year==2003')
51
+ # => { type: :CONSTRAINT, selector: "year", comparison: "==", argument: "2003" }
52
+ ```
53
+
54
+ ### `:COMBINATION` — two conditions joined by a logical operator
55
+
56
+ | Key | Type | Description |
57
+ |-------------|--------|-------------------------------------|
58
+ | `:type` | Symbol | Always `:COMBINATION` |
59
+ | `:operator` | Symbol | `:AND` or `:OR` |
60
+ | `:lhs` | Hash | Left-hand side node |
61
+ | `:rhs` | Hash | Right-hand side node |
62
+
63
+ ```ruby
64
+ RsqlParser.parse('a==1;b==2')
65
+ # => {
66
+ # type: :COMBINATION,
67
+ # operator: :AND,
68
+ # lhs: { type: :CONSTRAINT, selector: "a", comparison: "==", argument: "1" },
69
+ # rhs: { type: :CONSTRAINT, selector: "b", comparison: "==", argument: "2" }
70
+ # }
71
+ ```
72
+
73
+ ## Syntax Reference
74
+
75
+ ### Selectors
76
+
77
+ A selector is a field name consisting of unreserved characters: letters, digits, and `-._~:`.
78
+
79
+ ```
80
+ name
81
+ created_at
82
+ user.email
83
+ http://schema.org/name
84
+ ```
85
+
86
+ ### Comparison Operators
87
+
88
+ #### FIQL / RSQL operators
89
+
90
+ | Operator | Meaning | Example |
91
+ |-----------|--------------------------|----------------------|
92
+ | `==` | Equal | `name==Alice` |
93
+ | `!=` | Not equal | `status!=inactive` |
94
+ | `=gt=` | Greater than | `year=gt=2000` |
95
+ | `=gte=` | Greater than or equal | `year=gte=2000` |
96
+ | `=lt=` | Less than | `price=lt=100` |
97
+ | `=lte=` | Less than or equal | `price=lte=100` |
98
+ | `=in=` | In a set | `status=in=(a,b,c)` |
99
+ | `=out=` | Not in a set | `status=out=(x,y)` |
100
+ | `=custom=`| Any custom operator | `field=op=value` |
101
+
102
+ Custom FIQL operators follow the pattern `=[a-z!]*=` — any lowercase letters or `!` between two `=` signs.
103
+
104
+ #### Simplified comparison operators
105
+
106
+ | Operator | Meaning | Example |
107
+ |----------|--------------------------|---------------|
108
+ | `>` | Greater than | `year>2000` |
109
+ | `>=` | Greater than or equal | `year>=2000` |
110
+ | `<` | Less than | `price<100` |
111
+ | `<=` | Less than or equal | `price<=100` |
112
+
113
+ ### Logical Operators
114
+
115
+ Conditions can be combined with AND and OR. AND has higher precedence than OR.
116
+
117
+ #### Symbol syntax
118
+
119
+ | Symbol | Operator | Example |
120
+ |--------|----------|-------------------|
121
+ | `;` | AND | `a==1;b==2` |
122
+ | `,` | OR | `a==1,b==2` |
123
+
124
+ #### Keyword syntax (case-insensitive)
125
+
126
+ | Keyword | Operator | Example |
127
+ |---------------|----------|----------------------|
128
+ | `and` / `AND` | AND | `a==1 and b==2` |
129
+ | `or` / `OR` | OR | `a==1 or b==2` |
130
+
131
+ Symbol and keyword syntax can be mixed freely. Whitespace around keywords is ignored.
132
+
133
+ ### Arguments
134
+
135
+ #### Unquoted values
136
+
137
+ Sequences of unreserved characters (`[a-zA-Z0-9\-._~:]`):
138
+
139
+ ```
140
+ year==2003
141
+ status==active
142
+ date==2018-09-01T12:14:28Z
143
+ ```
144
+
145
+ #### Single-quoted strings
146
+
147
+ Allows spaces, semicolons, commas, and double quotes inside the value. Use `\'` to include a literal single quote:
148
+
149
+ ```
150
+ name=='Kill;"Bill"'
151
+ tag=='it\'s fine'
152
+ ```
153
+
154
+ #### Double-quoted strings
155
+
156
+ Allows spaces, semicolons, commas, and single quotes inside the value. Use `\"` to include a literal double quote:
157
+
158
+ ```
159
+ name=="Kill Bill"
160
+ title=="She said \"hello\""
161
+ ```
162
+
163
+ #### Array arguments
164
+
165
+ A parenthesised, comma-separated list. Used with operators like `=in=`:
166
+
167
+ ```
168
+ status=in=(active,pending,review)
169
+ name=in=("Kill Bill","Pulp Fiction")
170
+ ```
171
+
172
+ The `:argument` key will contain a Ruby `Array` instead of a `String`:
173
+
174
+ ```ruby
175
+ RsqlParser.parse('genre=in=(sci-fi,action)')
176
+ # => { type: :CONSTRAINT, selector: "genre", comparison: "=in=",
177
+ # argument: ["sci-fi", "action"] }
178
+ ```
179
+
180
+ ### Grouping
181
+
182
+ Parentheses override the default AND-before-OR precedence:
183
+
184
+ ```ruby
185
+ # Without grouping: (a AND b) OR c
186
+ RsqlParser.parse('a==1;b==2,c==3')
187
+
188
+ # With grouping: a AND (b OR c)
189
+ RsqlParser.parse('a==1;(b==2,c==3)')
190
+ ```
191
+
192
+ ## Examples
193
+
194
+ ```ruby
195
+ require 'rsql_parser'
196
+
197
+ # Single constraint
198
+ RsqlParser.parse('year==2003')
199
+ # => { type: :CONSTRAINT, selector: "year", comparison: "==", argument: "2003" }
200
+
201
+ # Simplified comparison syntax
202
+ RsqlParser.parse('price<=99')
203
+ # => { type: :CONSTRAINT, selector: "price", comparison: "<=", argument: "99" }
204
+
205
+ # AND combination (semicolon and keyword are equivalent)
206
+ RsqlParser.parse('name=="Kill Bill" and year=gt=2003')
207
+ RsqlParser.parse('name=="Kill Bill";year=gt=2003')
208
+
209
+ # OR combination
210
+ RsqlParser.parse('status==active or status==pending')
211
+ RsqlParser.parse('status==active,status==pending')
212
+
213
+ # Array argument
214
+ RsqlParser.parse("genre=in=(sci-fi,action);year>2000")
215
+ # => {
216
+ # type: :COMBINATION,
217
+ # operator: :AND,
218
+ # lhs: { type: :CONSTRAINT, selector: "genre", comparison: "=in=",
219
+ # argument: ["sci-fi", "action"] },
220
+ # rhs: { type: :CONSTRAINT, selector: "year", comparison: ">",
221
+ # argument: "2000" }
222
+ # }
223
+
224
+ # Chained AND — right-associative tree
225
+ RsqlParser.parse('a=eq=b;c=ne=d;e=gt=f')
226
+ # => {
227
+ # type: :COMBINATION, operator: :AND,
228
+ # lhs: { type: :CONSTRAINT, selector: "a", comparison: "=eq=", argument: "b" },
229
+ # rhs: {
230
+ # type: :COMBINATION, operator: :AND,
231
+ # lhs: { type: :CONSTRAINT, selector: "c", comparison: "=ne=", argument: "d" },
232
+ # rhs: { type: :CONSTRAINT, selector: "e", comparison: "=gt=", argument: "f" }
233
+ # }
234
+ # }
235
+
236
+ # Grouping to change precedence
237
+ RsqlParser.parse('a=eq=b;(c=ne=d,e=gt=f)')
238
+ # => {
239
+ # type: :COMBINATION, operator: :AND,
240
+ # lhs: { type: :CONSTRAINT, selector: "a", comparison: "=eq=", argument: "b" },
241
+ # rhs: {
242
+ # type: :COMBINATION, operator: :OR,
243
+ # lhs: { type: :CONSTRAINT, selector: "c", comparison: "=ne=", argument: "d" },
244
+ # rhs: { type: :CONSTRAINT, selector: "e", comparison: "=gt=", argument: "f" }
245
+ # }
246
+ # }
247
+
248
+ # Escaped quotes inside strings
249
+ RsqlParser.parse('title=="She said \"hello\""')
250
+ # => { type: :CONSTRAINT, selector: "title", comparison: "==",
251
+ # argument: 'She said "hello"' }
252
+ ```
253
+
254
+ ## Operator Precedence
255
+
256
+ From highest to lowest:
257
+
258
+ 1. Parentheses `( )`
259
+ 2. AND — `;` or `and`
260
+ 3. OR — `,` or `or`
261
+
262
+ ## Requirements
263
+
264
+ - Ruby >= 2.7.0
265
+ - [racc](https://github.com/ruby/racc) ~> 1.8
266
+
267
+ ## Development
268
+
269
+ ```bash
270
+ # Run tests
271
+ rake test
272
+
273
+ # Regenerate the lexer after editing lib/rsql_parser/lexer.rex
274
+ ruby -roedipus_lex -e "
275
+ lex = OedipusLex.new
276
+ lex.parse_file('lib/rsql_parser/lexer.rex')
277
+ File.write('lib/rsql_parser/lexer.rex.rb', lex.generate)
278
+ "
279
+ ```
280
+
281
+ Development dependencies: `oedipus_lex ~> 2.6`, `minitest ~> 5.21`, `rake ~> 13.0`.
282
+
283
+ ## Contributing
284
+
285
+ Open a pull request with your changes and a corresponding test.
286
+
287
+ ## License
288
+
289
+ MIT © [Ekzo](https://github.com/ekzo-dev)
data/Rakefile ADDED
@@ -0,0 +1,56 @@
1
+ require "rake/testtask"
2
+
3
+ GEMSPEC = Gem::Specification.load("rsql_ruby.gemspec")
4
+
5
+ Rake::TestTask.new(:test) do |t|
6
+ t.libs << "lib"
7
+ t.test_files = FileList["test/**/*_test.rb", "test/**/test_*.rb"]
8
+ t.verbose = false
9
+ end
10
+
11
+ LEX_INPUT = "lib/rsql_parser/lexer.rex"
12
+ LEX_OUTPUT = "lib/rsql_parser/lexer.rex.rb"
13
+
14
+ file LEX_OUTPUT => LEX_INPUT do
15
+ ruby <<~RUBY
16
+ -roedipus_lex -e "
17
+ lex = OedipusLex.new
18
+ lex.parse_file('#{LEX_INPUT}')
19
+ File.write('#{LEX_OUTPUT}', lex.generate)
20
+ "
21
+ RUBY
22
+ puts "Generated #{LEX_OUTPUT}"
23
+ end
24
+
25
+ desc "Regenerate #{LEX_OUTPUT} from #{LEX_INPUT}"
26
+ task :lexer => LEX_OUTPUT
27
+
28
+ PARSER_INPUT = "lib/rsql_parser/parser.y"
29
+ PARSER_OUTPUT = "lib/rsql_parser/parser.rb"
30
+
31
+ file PARSER_OUTPUT => PARSER_INPUT do
32
+ sh "racc #{PARSER_INPUT} -o #{PARSER_OUTPUT}"
33
+ puts "Generated #{PARSER_OUTPUT}"
34
+ end
35
+
36
+ desc "Regenerate #{PARSER_OUTPUT} from #{PARSER_INPUT}"
37
+ task :parser => PARSER_OUTPUT
38
+
39
+ desc "Regenerate lexer and parser from their source files"
40
+ task :generate => [:lexer, :parser]
41
+
42
+ GEM_FILE = "pkg/#{GEMSPEC.name}-#{GEMSPEC.version}.gem"
43
+
44
+ directory "pkg"
45
+
46
+ desc "Build #{GEMSPEC.name}-#{GEMSPEC.version}.gem into pkg/"
47
+ task :build => "pkg" do
48
+ sh "gem build rsql_ruby.gemspec --output #{GEM_FILE}"
49
+ end
50
+
51
+ desc "Build and push #{GEMSPEC.name}-#{GEMSPEC.version}.gem to RubyGems"
52
+ task :release => [:test, :build] do
53
+ sh "gem push #{GEM_FILE}"
54
+ end
55
+
56
+ task default: :test
@@ -0,0 +1,54 @@
1
+ module RsqlParser
2
+
3
+ class Parser < Racc::Parser
4
+
5
+ macro
6
+ BLANK /(\ |\t)+/
7
+ UNRESERVED /[a-zA-Z0-9\-._~:]+/
8
+ COMPARATOR />=|<=|!=|>|<|(=([a-z]|!)*=)/
9
+
10
+ # Operator query
11
+ AND_OPERATOR /;/
12
+ OR_COMMA_OPERATOR /,/
13
+
14
+ # String Parser
15
+ SINGLE_QUOTE /'/
16
+ DOUBLE_QUOTE /"/
17
+ SINGLE_QUOTED_STRING /((\\')|[^'])+/
18
+ DOUBLE_QUOTED_STRING /((\\")|[^"])+/
19
+
20
+ # Brakets
21
+ OPENING_BRACKET /\(/
22
+ CLOSING_BRACKET /\)/
23
+ ESCAPE /\\/
24
+
25
+ rule
26
+ /#{BLANK}/ { }
27
+ /#{ESCAPE}/ { [:ESCAPE, text] }
28
+ /and(?![a-zA-Z0-9\-._~:])/i { [:AND_OPERATOR, text] }
29
+ /or(?![a-zA-Z0-9\-._~:])/i { [:OR_COMMA_OPERATOR, text] }
30
+ /#{UNRESERVED}/ { [:UNRESERVED, text] }
31
+
32
+ /#{COMPARATOR}/ { [:COMPARATOR, text] }
33
+
34
+ /#{OR_COMMA_OPERATOR}/ { [:OR_COMMA_OPERATOR, text] }
35
+ /#{AND_OPERATOR}/ { [:AND_OPERATOR, text] }
36
+
37
+ /#{OPENING_BRACKET}/ { [:OPENING_BRACKET, text] }
38
+ /#{CLOSING_BRACKET}/ { [:CLOSING_BRACKET, text] }
39
+
40
+ /#{SINGLE_QUOTE}/ { self.state = :SINGLE_QUOTE; [:SINGLE_QUOTE, text] }
41
+ :SINGLE_QUOTE /#{SINGLE_QUOTED_STRING}/ { [:SINGLE_QUOTED_STRING, text.gsub("\\'", "'")] }
42
+ :SINGLE_QUOTE /#{SINGLE_QUOTE}/ { self.state = nil; [:SINGLE_QUOTE, text] }
43
+
44
+ /#{DOUBLE_QUOTE}/ { self.state = :DOUBLE_QUOTE; [:DOUBLE_QUOTE, text] }
45
+ :DOUBLE_QUOTE /#{DOUBLE_QUOTED_STRING}/ { [:DOUBLE_QUOTED_STRING, text.gsub("\\\"", '"')] }
46
+ :DOUBLE_QUOTE /#{DOUBLE_QUOTE}/ { self.state = nil; [:DOUBLE_QUOTE, text] }
47
+
48
+ option
49
+
50
+ inner
51
+
52
+ end
53
+
54
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+ # encoding: UTF-8
3
+ #--
4
+ # This file is automatically generated. Do not modify it.
5
+ # Generated by: oedipus_lex version 2.6.3.
6
+ # Source: lib/rsql_parser/lexer.rex
7
+ #++
8
+
9
+ module RsqlParser
10
+
11
+
12
+ ##
13
+ # The generated lexer Parser < Racc::Parser
14
+
15
+ class Parser < Racc::Parser
16
+ require 'strscan'
17
+
18
+ # :stopdoc:
19
+ BLANK = /(\ |\t)+/
20
+ UNRESERVED = /[a-zA-Z0-9\-._~:]+/
21
+ COMPARATOR = />=|<=|!=|>|<|(=([a-z]|!)*=)/
22
+ AND_OPERATOR = /;/
23
+ OR_COMMA_OPERATOR = /,/
24
+ SINGLE_QUOTE = /'/
25
+ DOUBLE_QUOTE = /"/
26
+ SINGLE_QUOTED_STRING = /((\\')|[^'])+/
27
+ DOUBLE_QUOTED_STRING = /((\\")|[^"])+/
28
+ OPENING_BRACKET = /\(/
29
+ CLOSING_BRACKET = /\)/
30
+ ESCAPE = /\\/
31
+ # :startdoc:
32
+ # :stopdoc:
33
+ class LexerError < StandardError ; end
34
+ class ScanError < LexerError ; end
35
+ # :startdoc:
36
+
37
+ ##
38
+ # The file name / path
39
+
40
+ attr_accessor :filename
41
+
42
+ ##
43
+ # The StringScanner for this lexer.
44
+
45
+ attr_accessor :ss
46
+
47
+ ##
48
+ # The current lexical state.
49
+
50
+ attr_accessor :state
51
+
52
+ alias :match :ss
53
+
54
+ ##
55
+ # The match groups for the current scan.
56
+
57
+ def matches
58
+ m = (1..9).map { |i| ss[i] }
59
+ m.pop until m[-1] or m.empty?
60
+ m
61
+ end
62
+
63
+ ##
64
+ # Yields on the current action.
65
+
66
+ def action
67
+ yield
68
+ end
69
+
70
+ ##
71
+ # The current scanner class. Must be overridden in subclasses.
72
+
73
+ def scanner_class
74
+ StringScanner
75
+ end unless instance_methods(false).map(&:to_s).include?("scanner_class")
76
+
77
+ ##
78
+ # Parse the given string.
79
+
80
+ def parse str
81
+ self.ss = scanner_class.new str
82
+ self.state ||= nil
83
+
84
+ do_parse
85
+ end
86
+
87
+ ##
88
+ # Read in and parse the file at +path+.
89
+
90
+ def parse_file path
91
+ self.filename = path
92
+ open path do |f|
93
+ parse f.read
94
+ end
95
+ end
96
+
97
+ ##
98
+ # The current location in the parse.
99
+
100
+ def location
101
+ [
102
+ (filename || "<input>"),
103
+ ].compact.join(":")
104
+ end
105
+
106
+ ##
107
+ # Lex the next token.
108
+
109
+ def next_token
110
+
111
+ token = nil
112
+
113
+ until ss.eos? or token do
114
+ token =
115
+ case state
116
+ when nil then
117
+ case
118
+ when ss.skip(/#{BLANK}/) then
119
+ action { }
120
+ when text = ss.scan(/#{ESCAPE}/) then
121
+ action { [:ESCAPE, text] }
122
+ when text = ss.scan(/and(?![a-zA-Z0-9\-._~:])/i) then
123
+ action { [:AND_OPERATOR, text] }
124
+ when text = ss.scan(/or(?![a-zA-Z0-9\-._~:])/i) then
125
+ action { [:OR_COMMA_OPERATOR, text] }
126
+ when text = ss.scan(/#{UNRESERVED}/) then
127
+ action { [:UNRESERVED, text] }
128
+ when text = ss.scan(/#{COMPARATOR}/) then
129
+ action { [:COMPARATOR, text] }
130
+ when text = ss.scan(/#{OR_COMMA_OPERATOR}/) then
131
+ action { [:OR_COMMA_OPERATOR, text] }
132
+ when text = ss.scan(/#{AND_OPERATOR}/) then
133
+ action { [:AND_OPERATOR, text] }
134
+ when text = ss.scan(/#{OPENING_BRACKET}/) then
135
+ action { [:OPENING_BRACKET, text] }
136
+ when text = ss.scan(/#{CLOSING_BRACKET}/) then
137
+ action { [:CLOSING_BRACKET, text] }
138
+ when text = ss.scan(/#{SINGLE_QUOTE}/) then
139
+ action { self.state = :SINGLE_QUOTE; [:SINGLE_QUOTE, text] }
140
+ when text = ss.scan(/#{DOUBLE_QUOTE}/) then
141
+ action { self.state = :DOUBLE_QUOTE; [:DOUBLE_QUOTE, text] }
142
+ else
143
+ text = ss.string[ss.pos .. -1]
144
+ raise ScanError, "can not match (#{state.inspect}) at #{location}: '#{text}'"
145
+ end
146
+ when :SINGLE_QUOTE then
147
+ case
148
+ when text = ss.scan(/#{SINGLE_QUOTED_STRING}/) then
149
+ action { [:SINGLE_QUOTED_STRING, text.gsub("\\'", "'")] }
150
+ when text = ss.scan(/#{SINGLE_QUOTE}/) then
151
+ action { self.state = nil; [:SINGLE_QUOTE, text] }
152
+ else
153
+ text = ss.string[ss.pos .. -1]
154
+ raise ScanError, "can not match (#{state.inspect}) at #{location}: '#{text}'"
155
+ end
156
+ when :DOUBLE_QUOTE then
157
+ case
158
+ when text = ss.scan(/#{DOUBLE_QUOTED_STRING}/) then
159
+ action { [:DOUBLE_QUOTED_STRING, text.gsub("\\\"", '"')] }
160
+ when text = ss.scan(/#{DOUBLE_QUOTE}/) then
161
+ action { self.state = nil; [:DOUBLE_QUOTE, text] }
162
+ else
163
+ text = ss.string[ss.pos .. -1]
164
+ raise ScanError, "can not match (#{state.inspect}) at #{location}: '#{text}'"
165
+ end
166
+ else
167
+ raise ScanError, "undefined state at #{location}: '#{state}'"
168
+ end # token = case state
169
+
170
+ next unless token # allow functions to trigger redo w/ nil
171
+ end # while
172
+
173
+ raise LexerError, "bad lexical result at #{location}: #{token.inspect}" unless
174
+ token.nil? || (Array === token && token.size >= 2)
175
+
176
+ # auto-switch state
177
+ self.state = token.last if token && token.first == :state
178
+
179
+ token
180
+ end # def next_token
181
+ end # class
182
+
183
+ end
@@ -0,0 +1,254 @@
1
+ #
2
+ # DO NOT MODIFY!!!!
3
+ # This file is automatically generated by Racc 1.8.1
4
+ # from Racc grammar file "parser.y".
5
+ #
6
+
7
+ require 'racc/parser.rb'
8
+
9
+ # Generated by racc
10
+ require_relative 'lexer.rex'
11
+
12
+ module RsqlParser
13
+ class Parser < Racc::Parser
14
+
15
+ module_eval(<<'...end parser.y/module_eval...', 'parser.y', 44)
16
+
17
+ def create_logical_operator(operator, lhs, rhs)
18
+ {
19
+ type: :COMBINATION,
20
+ operator: operator,
21
+ lhs: lhs,
22
+ rhs: rhs
23
+ }
24
+ end
25
+
26
+ def create_constraint(selector, comparison, argument)
27
+ {
28
+ type: :CONSTRAINT,
29
+ selector: selector,
30
+ comparison: comparison,
31
+ argument: argument
32
+ }
33
+ end
34
+
35
+ ...end parser.y/module_eval...
36
+ ##### State transition tables begin ###
37
+
38
+ racc_action_table = [
39
+ 19, 8, 20, 21, 9, 22, 25, 20, 21, 30,
40
+ 22, 20, 21, 29, 22, 6, 6, 7, 7, 6,
41
+ 6, 7, 7, 10, 11, 13, 23, 27, 28, 31,
42
+ 32 ]
43
+
44
+ racc_action_check = [
45
+ 11, 1, 11, 11, 3, 11, 19, 19, 19, 24,
46
+ 19, 30, 30, 24, 30, 0, 6, 0, 6, 9,
47
+ 10, 9, 10, 4, 5, 8, 12, 21, 22, 27,
48
+ 28 ]
49
+
50
+ racc_action_pointer = [
51
+ 8, 1, nil, 0, 18, 18, 9, nil, 25, 12,
52
+ 13, -7, 18, nil, nil, nil, nil, nil, nil, -2,
53
+ nil, 16, 15, nil, 5, nil, nil, 19, 18, nil,
54
+ 2, nil, nil, nil ]
55
+
56
+ racc_action_default = [
57
+ -19, -19, -1, -3, -5, -7, -19, -9, -19, -19,
58
+ -19, -19, -19, 34, -2, -4, -6, -10, -11, -19,
59
+ -16, -19, -19, -8, -19, -13, -14, -19, -19, -12,
60
+ -19, -17, -18, -15 ]
61
+
62
+ racc_goto_table = [
63
+ 18, 2, 1, 15, 16, 17, 24, 12, 26, nil,
64
+ 14, nil, nil, nil, nil, nil, nil, nil, nil, 33 ]
65
+
66
+ racc_goto_check = [
67
+ 8, 2, 1, 3, 6, 7, 9, 2, 8, nil,
68
+ 2, nil, nil, nil, nil, nil, nil, nil, nil, 8 ]
69
+
70
+ racc_goto_pointer = [
71
+ nil, 2, 1, -7, nil, nil, -7, -6, -11, -13 ]
72
+
73
+ racc_goto_default = [
74
+ nil, nil, nil, 3, 4, 5, nil, nil, nil, nil ]
75
+
76
+ racc_reduce_table = [
77
+ 0, 0, :racc_error,
78
+ 1, 15, :_reduce_none,
79
+ 3, 16, :_reduce_2,
80
+ 1, 16, :_reduce_none,
81
+ 3, 17, :_reduce_4,
82
+ 1, 17, :_reduce_none,
83
+ 3, 18, :_reduce_6,
84
+ 1, 18, :_reduce_none,
85
+ 3, 18, :_reduce_8,
86
+ 1, 19, :_reduce_none,
87
+ 1, 20, :_reduce_none,
88
+ 1, 20, :_reduce_none,
89
+ 3, 21, :_reduce_12,
90
+ 2, 21, :_reduce_13,
91
+ 1, 23, :_reduce_14,
92
+ 3, 23, :_reduce_15,
93
+ 1, 22, :_reduce_none,
94
+ 3, 22, :_reduce_17,
95
+ 3, 22, :_reduce_18 ]
96
+
97
+ racc_reduce_n = 19
98
+
99
+ racc_shift_n = 34
100
+
101
+ racc_token_table = {
102
+ false => 0,
103
+ :error => 1,
104
+ ";" => 2,
105
+ "," => 3,
106
+ :OR_COMMA_OPERATOR => 4,
107
+ :AND_OPERATOR => 5,
108
+ :COMPARATOR => 6,
109
+ :OPENING_BRACKET => 7,
110
+ :CLOSING_BRACKET => 8,
111
+ :UNRESERVED => 9,
112
+ :SINGLE_QUOTE => 10,
113
+ :SINGLE_QUOTED_STRING => 11,
114
+ :DOUBLE_QUOTE => 12,
115
+ :DOUBLE_QUOTED_STRING => 13 }
116
+
117
+ racc_nt_base = 14
118
+
119
+ racc_use_result_var = false
120
+
121
+ Racc_arg = [
122
+ racc_action_table,
123
+ racc_action_check,
124
+ racc_action_default,
125
+ racc_action_pointer,
126
+ racc_goto_table,
127
+ racc_goto_check,
128
+ racc_goto_default,
129
+ racc_goto_pointer,
130
+ racc_nt_base,
131
+ racc_reduce_table,
132
+ racc_token_table,
133
+ racc_shift_n,
134
+ racc_reduce_n,
135
+ racc_use_result_var ]
136
+ Ractor.make_shareable(Racc_arg) if defined?(Ractor)
137
+
138
+ Racc_token_to_s_table = [
139
+ "$end",
140
+ "error",
141
+ "\";\"",
142
+ "\",\"",
143
+ "OR_COMMA_OPERATOR",
144
+ "AND_OPERATOR",
145
+ "COMPARATOR",
146
+ "OPENING_BRACKET",
147
+ "CLOSING_BRACKET",
148
+ "UNRESERVED",
149
+ "SINGLE_QUOTE",
150
+ "SINGLE_QUOTED_STRING",
151
+ "DOUBLE_QUOTE",
152
+ "DOUBLE_QUOTED_STRING",
153
+ "$start",
154
+ "target",
155
+ "disjunction",
156
+ "conjuction",
157
+ "constraint",
158
+ "selector",
159
+ "argument",
160
+ "array",
161
+ "value",
162
+ "contents" ]
163
+ Ractor.make_shareable(Racc_token_to_s_table) if defined?(Ractor)
164
+
165
+ Racc_debug_parser = false
166
+
167
+ ##### State transition tables end #####
168
+
169
+ # reduce 0 omitted
170
+
171
+ # reduce 1 omitted
172
+
173
+ module_eval(<<'.,.,', 'parser.y', 12)
174
+ def _reduce_2(val, _values)
175
+ create_logical_operator(:OR, val[0], val[2])
176
+ end
177
+ .,.,
178
+
179
+ # reduce 3 omitted
180
+
181
+ module_eval(<<'.,.,', 'parser.y', 15)
182
+ def _reduce_4(val, _values)
183
+ create_logical_operator(:AND, val[0], val[2])
184
+ end
185
+ .,.,
186
+
187
+ # reduce 5 omitted
188
+
189
+ module_eval(<<'.,.,', 'parser.y', 18)
190
+ def _reduce_6(val, _values)
191
+ create_constraint(val[0], val[1], val[2])
192
+ end
193
+ .,.,
194
+
195
+ # reduce 7 omitted
196
+
197
+ module_eval(<<'.,.,', 'parser.y', 20)
198
+ def _reduce_8(val, _values)
199
+ val[1]
200
+ end
201
+ .,.,
202
+
203
+ # reduce 9 omitted
204
+
205
+ # reduce 10 omitted
206
+
207
+ # reduce 11 omitted
208
+
209
+ module_eval(<<'.,.,', 'parser.y', 27)
210
+ def _reduce_12(val, _values)
211
+ val[1]
212
+ end
213
+ .,.,
214
+
215
+ module_eval(<<'.,.,', 'parser.y', 28)
216
+ def _reduce_13(val, _values)
217
+ []
218
+ end
219
+ .,.,
220
+
221
+ module_eval(<<'.,.,', 'parser.y', 30)
222
+ def _reduce_14(val, _values)
223
+ [val[0]]
224
+ end
225
+ .,.,
226
+
227
+ module_eval(<<'.,.,', 'parser.y', 31)
228
+ def _reduce_15(val, _values)
229
+ val[0].push(val[2]); val[0]
230
+ end
231
+ .,.,
232
+
233
+ # reduce 16 omitted
234
+
235
+ module_eval(<<'.,.,', 'parser.y', 34)
236
+ def _reduce_17(val, _values)
237
+ val[1]
238
+ end
239
+ .,.,
240
+
241
+ module_eval(<<'.,.,', 'parser.y', 35)
242
+ def _reduce_18(val, _values)
243
+ val[1]
244
+ end
245
+ .,.,
246
+
247
+ def _reduce_none(val, _values)
248
+ val[0]
249
+ end
250
+
251
+ end # class Parser
252
+ end # module RsqlParser
253
+
254
+
@@ -0,0 +1,63 @@
1
+ # RSQL parser
2
+
3
+ class RsqlParser::Parser
4
+ prechigh
5
+ left ';'
6
+ left ','
7
+ preclow
8
+ options
9
+ no_result_var
10
+ rule
11
+ target : disjunction
12
+
13
+ disjunction : conjuction OR_COMMA_OPERATOR disjunction { create_logical_operator(:OR, val[0], val[2]) }
14
+ | conjuction
15
+
16
+ conjuction : constraint AND_OPERATOR conjuction { create_logical_operator(:AND, val[0], val[2]) }
17
+ | constraint
18
+
19
+ constraint : selector COMPARATOR argument { create_constraint(val[0], val[1], val[2]) }
20
+ | selector
21
+ | OPENING_BRACKET disjunction CLOSING_BRACKET { val[1] }
22
+
23
+ selector : UNRESERVED
24
+
25
+ argument : array
26
+ | value
27
+
28
+ array : OPENING_BRACKET contents CLOSING_BRACKET { val[1] }
29
+ | OPENING_BRACKET CLOSING_BRACKET { [] }
30
+
31
+ contents : value { [val[0]] }
32
+ | contents OR_COMMA_OPERATOR value { val[0].push(val[2]); val[0] }
33
+
34
+ value : UNRESERVED
35
+ | SINGLE_QUOTE SINGLE_QUOTED_STRING SINGLE_QUOTE { val[1] }
36
+ | DOUBLE_QUOTE DOUBLE_QUOTED_STRING DOUBLE_QUOTE { val[1] }
37
+ end
38
+
39
+ ---- header
40
+ # Generated by racc
41
+ require_relative 'lexer.rex'
42
+
43
+ ---- inner
44
+
45
+ def create_logical_operator(operator, lhs, rhs)
46
+ {
47
+ type: :COMBINATION,
48
+ operator: operator,
49
+ lhs: lhs,
50
+ rhs: rhs
51
+ }
52
+ end
53
+
54
+ def create_constraint(selector, comparison, argument)
55
+ {
56
+ type: :CONSTRAINT,
57
+ selector: selector,
58
+ comparison: comparison,
59
+ argument: argument
60
+ }
61
+ end
62
+
63
+ ---- footer
@@ -0,0 +1,3 @@
1
+ module RsqlParser
2
+ VERSION = "1.0.0"
3
+ end
@@ -0,0 +1,9 @@
1
+ require_relative 'rsql_parser/parser'
2
+
3
+ module RsqlParser
4
+ # Parse string to AST nodes
5
+ def self.parse(str)
6
+ parser = RsqlParser::Parser.new
7
+ parser.parse(str)
8
+ end
9
+ end
data/rsql_ruby.gemspec ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'lib/rsql_parser/version'
3
+
4
+ Gem::Specification.new do |spec|
5
+ spec.name = 'rsql_parser'
6
+ spec.version = RsqlParser::VERSION
7
+ spec.authors = ['Dmitry Arkhipov']
8
+ spec.email = ['d.arkhipov@ekzo.dev']
9
+
10
+ spec.summary = 'RSQL/FIQL parser for Ruby'
11
+ spec.description = 'Parse RSQL/FIQL string expression into AST nodes'
12
+
13
+ spec.homepage = 'https://github.com/ekzo-dev/ruby-rsql'
14
+ spec.license = 'MIT'
15
+ spec.required_ruby_version = '>= 2.7.0'
16
+
17
+ spec.metadata['homepage_uri'] = spec.homepage
18
+ spec.metadata['source_code_uri'] = spec.homepage
19
+ spec.metadata['changelog_uri'] = 'https://github.com/ekzo-dev/ruby-rsql/CHANGELOG.md'
20
+
21
+ # Specify which files should be added to the gem when it is released.
22
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
23
+ spec.files = Dir.chdir(__dir__) do
24
+ `git ls-files -z`.split("\x0").reject do |f|
25
+ (File.expand_path(f) == __FILE__) || f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor])
26
+ end
27
+ end
28
+ spec.bindir = 'exe'
29
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
30
+ spec.require_paths = ['lib']
31
+
32
+ spec.add_dependency 'racc', '~> 1.8'
33
+ spec.add_development_dependency 'oedipus_lex', '~> 2.6'
34
+ spec.add_development_dependency 'minitest', '~> 5.21'
35
+ spec.add_development_dependency 'rake', '~> 13.0'
36
+ end
@@ -0,0 +1,14 @@
1
+ # RSQL parsed AST
2
+ type rsql_ast = {
3
+ type: :COMBINATION | :CONSTRAINT,
4
+ ?lhs: rsql_ast,
5
+ ?rhs: rsql_ast,
6
+ ?selector: String,
7
+ ?comparison: String,
8
+ ?argument: String,
9
+ }
10
+
11
+ module RsqlParser
12
+ # Parse string to AST nodes
13
+ def self.parse: (String str) -> rsql_ast
14
+ end
metadata ADDED
@@ -0,0 +1,115 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rsql_parser
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Dmitry Arkhipov
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-03-27 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: racc
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.8'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.8'
27
+ - !ruby/object:Gem::Dependency
28
+ name: oedipus_lex
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.6'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.6'
41
+ - !ruby/object:Gem::Dependency
42
+ name: minitest
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '5.21'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '5.21'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '13.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '13.0'
69
+ description: Parse RSQL/FIQL string expression into AST nodes
70
+ email:
71
+ - d.arkhipov@ekzo.dev
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - CHANGELOG.md
77
+ - Gemfile
78
+ - LICENSE
79
+ - README.md
80
+ - Rakefile
81
+ - lib/rsql_parser.rb
82
+ - lib/rsql_parser/lexer.rex
83
+ - lib/rsql_parser/lexer.rex.rb
84
+ - lib/rsql_parser/parser.rb
85
+ - lib/rsql_parser/parser.y
86
+ - lib/rsql_parser/version.rb
87
+ - rsql_ruby.gemspec
88
+ - sig/rsql_parser.rbs
89
+ homepage: https://github.com/ekzo-dev/ruby-rsql
90
+ licenses:
91
+ - MIT
92
+ metadata:
93
+ homepage_uri: https://github.com/ekzo-dev/ruby-rsql
94
+ source_code_uri: https://github.com/ekzo-dev/ruby-rsql
95
+ changelog_uri: https://github.com/ekzo-dev/ruby-rsql/CHANGELOG.md
96
+ post_install_message:
97
+ rdoc_options: []
98
+ require_paths:
99
+ - lib
100
+ required_ruby_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: 2.7.0
105
+ required_rubygems_version: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ requirements: []
111
+ rubygems_version: 3.5.22
112
+ signing_key:
113
+ specification_version: 4
114
+ summary: RSQL/FIQL parser for Ruby
115
+ test_files: []