Class: Scimitar::Lists::QueryParser

Inherits:
Object
  • Object
show all
Defined in:
app/models/scimitar/lists/query_parser.rb

Overview

Simple SCIM filter support.

This is currently an extremely limited query parser supporting only a single “name-operator-value” query, no boolean operations or precedence operators and it assumes “(I)LIKE” and “%” as wildcards in SQL for any operators which require partial match (contains / “co”, starts with / “sw”, ends with / “ew”). Generic operations don’t support “pr” either (‘presence’).

Create an instance, then construct a query appropriate for your storage back-end using #attribute to get the attribute name (in terms of “your data”, via your Scimitar::Resources::Mixin-including class implementation of ::scim_queryable_attributes), #operator to get a generic SQL operator such as “=” or “!=” and #parameter to get the value to be found (which you MUST take care to process so as to avoid an SQL injection or similar issues - use escaping suitable for your storage system’s query language).

  • If you don’t want to support (I)LIKE just check for it in #parameter’s return value; it’ll be upper case.

Given the likelihood of using ActiveRecord via Rails, there’s a higher level and easier method - just create the instance, then call QueryParser#to_activerecord_query to get a given base scope narrowed down to match the filter parameters.

Constant Summary collapse

OPERATORS =

Combined operator precedence.

{
  'pr' => 4,

  'eq' => 3,
  'ne' => 3,
  'gt' => 3,
  'ge' => 3,
  'lt' => 3,
  'le' => 3,
  'co' => 3,
  'sw' => 3,
  'ew' => 3,

  'and' => 2,
  'or'  => 1
}.freeze
UNARY_OPERATORS =

Unary operators.

Set.new([
  'pr'
]).freeze
BINARY_OPERATORS =

Binary operators.

Set.new(OPERATORS.keys.reject { |op| UNARY_OPERATORS.include?(op) }).freeze
PAREN =

Tokenizing expressions

/[\(\)]/.freeze
STR =
/"(?:\\"|[^"])*"/.freeze
OP =
/#{OPERATORS.keys.join('|')}/i.freeze
WORD =
/[\w\.]+/.freeze
SEP =
/\s?/.freeze
NEXT_TOKEN =
/\A(#{PAREN}|#{STR}|#{OP}|#{WORD})#{SEP}/.freeze
IS_OPERATOR =
/\A(?:#{OP})\Z/.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(attribute_map) ⇒ QueryParser

Initialise an object.

attribute_map

See Scimitar::Resources::Mixin and documentation on implementing ::scim_queryable_attributes; pass that method’s return value here.



80
81
82
# File 'app/models/scimitar/lists/query_parser.rb', line 80

def initialize(attribute_map)
  @attribute_map = attribute_map.with_indifferent_case_insensitive_access()
end

Instance Attribute Details

#attribute_mapObject (readonly)

Returns the value of attribute attribute_map.



31
32
33
# File 'app/models/scimitar/lists/query_parser.rb', line 31

def attribute_map
  @attribute_map
end

#rpnObject (readonly)

Returns the value of attribute rpn.



31
32
33
# File 'app/models/scimitar/lists/query_parser.rb', line 31

def rpn
  @rpn
end

Instance Method Details

#parse(input) ⇒ Object

Parse SCIM filter query into RPN stack

input

Input filter string, e.g. ‘givenName eq “Briony”’.

Returns a “self” for convenience. Call #rpn thereafter to retrieve the parsed RPN stack. For example, given this input:

userType eq "Employee" and (emails co "example.com" or emails co "example.org")

…returns a parser object wherein #rpn will yield:

[
  'userType',
  '"Employee"',
  'eq',
  'emails',
  '"example.com"',
  'co',
  'emails',
  '"example.org"',
  'co',
  'or',
  'and'
]

Alternatively, call #tree to get an expression tree:

[
  'and',
  [
    'eq',
    'userType',
    '"Employee"'
  ],
  [
    'or',
    [
      'co',
      'emails',
      '"example.com"'
    ],
    [
      'co',
      'emails',
      '"example.org"'
    ]
  ]
]


133
134
135
136
137
138
139
140
141
142
# File 'app/models/scimitar/lists/query_parser.rb', line 133

def parse(input)
  preprocessed_input = flatten_filter(input) rescue input

  @input  = input.clone() # Saved just for error msgs
  @tokens = self.lex(preprocessed_input)
  @rpn    = self.parse_expr()

  self.assert_eos()
  self
end

#to_activerecord_query(base_scope) ⇒ Object

Having called #parse, call here to generate an ActiveRecord query based on a given starting scope. The scope is used for all ‘and’ queries and as a basis for any nested ‘or’ scopes. For example, given this input:

userType eq "Employee" and (emails eq "[email protected]" or emails eq "[email protected]")

…and if you passed ‘User.active’ as a scope, there would be something along these lines sent to ActiveRecord:

User.active.where(user_type: 'Employee').and(User.active.where(work_email: '[email protected]').or(User.active.where(work_email: '[email protected]')))

See query_parser_spec.rb to get an idea for expected SQL based on various kinds of input, especially section “context ‘with complex cases’ do”.

base_scope

The starting scope, e.g. User.active.

Returns an ActiveRecord::Relation giving an SQL query that is the gem’s best attempt at interpreting the SCIM filter string.



173
174
175
176
177
178
# File 'app/models/scimitar/lists/query_parser.rb', line 173

def to_activerecord_query(base_scope)
  return self.to_activerecord_query_backend(
    base_scope:      base_scope,
    expression_tree: self.tree()
  )
end

#treeObject

Transform the RPN stack into a tree, returning the result. A new tree is created each time, so you can mutate the result if need be.

See #parse for more information.



149
150
151
152
# File 'app/models/scimitar/lists/query_parser.rb', line 149

def tree
  @stack = @rpn.clone()
  self.get_tree()
end