class Sparkql::FunctionResolver

Binding class to all supported function calls in the parser. Current support requires that the resolution of function calls to happen on the fly at parsing time at which point a value and value type is required, just as literals would be returned to the expression tokenization level.

Name and argument requirements for the function should match the function declaration in SUPPORTED_FUNCTIONS which will run validation on the function syntax prior to execution.

Constants

MAX_DATE_TIME
MIN_DATE_TIME
SECONDS_IN_DAY
SECONDS_IN_HOUR
SECONDS_IN_MINUTE
STRFTIME_DATE_FORMAT
STRFTIME_TIME_FORMAT
SUPPORTED_FUNCTIONS
VALID_CAST_TYPES
VALID_REGEX_FLAGS

Attributes

errors[R]

Public Class Methods

new(name, args) click to toggle source

Construct a resolver instance for a function name: function name (String) args: array of literal hashes of the format {:type=><literal_type>, :value=><escaped_literal_value>}.

Empty arry for functions that have no arguments.
# File lib/sparkql/function_resolver.rb, line 232
def initialize(name, args)
  @name = name
  @args = args
  @errors = []
end

Public Instance Methods

call() click to toggle source

Execute the function

# File lib/sparkql/function_resolver.rb, line 304
def call
  real_vals = @args.map { |i| i[:value] }
  name = @name.to_sym

  field = @args.find do |i|
    i[:type] == :field || i.key?(:field)
  end

  field = field[:type] == :function ? field[:field] : field[:value] unless field.nil?

  required_args = support[name][:args]
  total_args = required_args + Array(support[name][:opt_args]).collect { |args| args[:default] }

  fill_in_optional_args = total_args.drop(real_vals.length)

  fill_in_optional_args.each do |default|
    real_vals << default
  end

  v = if field.nil?
        method = name
        if support[name][:resolve_for_type]
          method_type = @args.first[:type]
          method = "#{method}_#{method_type}"
        end
        send(method, *real_vals)
      else
        {
          type: :function,
          return_type: return_type,
          value: name.to_s
        }
      end

  return if v.nil?

  unless v.key?(:function_name)
    v.merge!(function_parameters: real_vals,
             function_name: @name)
  end

  v.merge!(args: @args,
           field: field)

  v
end
errors?() click to toggle source
# File lib/sparkql/function_resolver.rb, line 295
def errors?
  @errors.size.positive?
end
return_type() click to toggle source
# File lib/sparkql/function_resolver.rb, line 283
def return_type
  name = @name.to_sym

  if name == :cast
    @args.last[:value].to_sym
  else
    support[@name.to_sym][:return_type]
  end
end
support() click to toggle source
# File lib/sparkql/function_resolver.rb, line 299
def support
  SUPPORTED_FUNCTIONS
end
validate() click to toggle source

Validate the function instance prior to calling it. All validation failures will show up in the errors array.

# File lib/sparkql/function_resolver.rb, line 240
def validate
  name = @name.to_sym
  unless support.key?(name)
    @errors << Sparkql::ParserError.new(token: @name,
                                        message: "Unsupported function call '#{@name}' for expression",
                                        status: :fatal)
    return
  end

  required_args = support[name][:args]
  total_args = required_args + Array(support[name][:opt_args]).collect { |args| args[:type] }

  if @args.size < required_args.size || @args.size > total_args.size
    @errors << Sparkql::ParserError.new(token: @name,
                                        message: "Function call '#{@name}' requires #{required_args.size} arguments",
                                        status: :fatal)
    return
  end

  count = 0
  @args.each do |arg|
    type = arg[:type] == :function ? arg[:return_type] : arg[:type]
    unless Array(total_args[count]).include?(type)
      @errors << Sparkql::ParserError.new(token: @name,
                                          message: "Function call '#{@name}' has an invalid argument at #{arg[:value]}",
                                          status: :fatal)
    end
    count += 1
  end

  if name == :cast
    type = @args.last[:value]
    unless VALID_CAST_TYPES.include?(type.to_sym)
      @errors << Sparkql::ParserError.new(token: @name,
                                          message: "Function call '#{@name}' requires a castable type.",
                                          status: :fatal)
      return
    end
  end

  substring_index_error?(@args[2][:value]) if name == :substring && !@args[2].nil?
end

Protected Instance Methods

cast(value, type) click to toggle source
# File lib/sparkql/function_resolver.rb, line 771
def cast(value, type)
  value = nil if value == 'NULL'

  new_type = type.to_sym
  {
    type: new_type,
    value: cast_literal(value, new_type)
  }
rescue StandardError
  {
    type: :null,
    value: 'NULL'
  }
end
cast_character(value, type) click to toggle source
# File lib/sparkql/function_resolver.rb, line 805
def cast_character(value, type)
  cast(value, type)
end
cast_decimal(value, type) click to toggle source
# File lib/sparkql/function_resolver.rb, line 801
def cast_decimal(value, type)
  cast(value, type)
end
cast_literal(value, type) click to toggle source
# File lib/sparkql/function_resolver.rb, line 809
def cast_literal(value, type)
  case type
  when :character
    "'#{value}'"
  when :integer
    if value.nil?
      '0'
    else
      Integer(Float(value)).to_s
    end
  when :decimal
    if value.nil?
      '0.0'
    else
      Float(value).to_s
    end
  when :null
    'NULL'
  end
end
cast_null(value, type) click to toggle source
# File lib/sparkql/function_resolver.rb, line 797
def cast_null(value, type)
  cast(value, type)
end
ceiling_decimal(arg) click to toggle source
# File lib/sparkql/function_resolver.rb, line 597
def ceiling_decimal(arg)
  {
    type: :integer,
    value: arg.ceil.to_s
  }
end
concat_character(arg1, arg2) click to toggle source
# File lib/sparkql/function_resolver.rb, line 618
def concat_character(arg1, arg2)
  {
    type: :character,
    value: "'#{arg1}#{arg2}'"
  }
end
contains(string) click to toggle source
# File lib/sparkql/function_resolver.rb, line 476
def contains(string)
  # Wrap this string in quotes, as we effectively translate
  #   City Eq contains('far')
  # ...to...
  #    City Eq regex('far')
  #
  # The string passed in will merely be "far", rather than
  # the string literal "'far'".
  string = Regexp.escape(string)
  new_value = string.to_s

  {
    function_name: 'regex',
    function_parameters: [new_value, ''],
    type: :character,
    value: new_value
  }
end
current_date() click to toggle source
# File lib/sparkql/function_resolver.rb, line 830
def current_date
  current_timestamp.to_date
end
current_time() click to toggle source
# File lib/sparkql/function_resolver.rb, line 834
def current_time
  current_timestamp.to_time
end
current_timestamp() click to toggle source
# File lib/sparkql/function_resolver.rb, line 838
def current_timestamp
  @current_timestamp ||= DateTime.now
end
date_datetime(datetime) click to toggle source
# File lib/sparkql/function_resolver.rb, line 625
def date_datetime(datetime)
  {
    type: :date,
    value: datetime.strftime(STRFTIME_DATE_FORMAT)
  }
end
days(number_of_days) click to toggle source

Offset the current timestamp by a number of days

# File lib/sparkql/function_resolver.rb, line 523
def days(number_of_days)
  # date calculated as the offset from midnight tommorrow. Zero will provide values for all times
  # today.
  d = current_date + number_of_days
  {
    type: :date,
    value: d.strftime(STRFTIME_DATE_FORMAT)
  }
end
endswith(string) click to toggle source
# File lib/sparkql/function_resolver.rb, line 457
def endswith(string)
  # Wrap this string in quotes, as we effectively translate
  #   City Eq endswith('far')
  # ...to...
  #    City Eq regex('far$')
  #
  # The string passed in will merely be "far", rather than
  # the string literal "'far'".
  string = Regexp.escape(string)
  new_value = "#{string}$"

  {
    function_name: 'regex',
    function_parameters: [new_value, ''],
    type: :character,
    value: new_value
  }
end
floor_decimal(arg) click to toggle source
# File lib/sparkql/function_resolver.rb, line 590
def floor_decimal(arg)
  {
    type: :integer,
    value: arg.floor.to_s
  }
end
hours(num) click to toggle source

Offset the current timestamp by a number of hours

# File lib/sparkql/function_resolver.rb, line 514
def hours(num)
  t = current_time + num * SECONDS_IN_HOUR
  {
    type: :datetime,
    value: t.iso8601
  }
end
indexof(arg1, arg2) click to toggle source
# File lib/sparkql/function_resolver.rb, line 611
def indexof(arg1, arg2)
  {
    value: 'indexof',
    args: [arg1, arg2]
  }
end
length_character(string) click to toggle source
# File lib/sparkql/function_resolver.rb, line 431
def length_character(string)
  {
    type: :integer,
    value: string.size.to_s
  }
end
linestring(coords) click to toggle source
# File lib/sparkql/function_resolver.rb, line 674
def linestring(coords)
  new_coords = parse_coordinates(coords)
  unless new_coords.size > 1
    @errors << Sparkql::ParserError.new(token: coords,
                                        message: "Function call 'linestring' requires at least two coordinates",
                                        status: :fatal)
    return
  end

  shape = GeoRuby::SimpleFeatures::LineString.from_coordinates(new_coords)
  {
    type: :shape,
    value: shape
  }
end
maxdatetime() click to toggle source
# File lib/sparkql/function_resolver.rb, line 576
def maxdatetime
  {
    type: :datetime,
    value: MAX_DATE_TIME
  }
end
mindatetime() click to toggle source
# File lib/sparkql/function_resolver.rb, line 583
def mindatetime
  {
    type: :datetime,
    value: MIN_DATE_TIME
  }
end
minutes(num) click to toggle source

Offset the current timestamp by a number of minutes

# File lib/sparkql/function_resolver.rb, line 505
def minutes(num)
  t = current_time + num * SECONDS_IN_MINUTE
  {
    type: :datetime,
    value: t.iso8601
  }
end
months(num_months) click to toggle source
# File lib/sparkql/function_resolver.rb, line 639
def months(num_months)
  d = current_timestamp >> num_months
  {
    type: :date,
    value: d.strftime(STRFTIME_DATE_FORMAT)
  }
end
now() click to toggle source

The current timestamp

# File lib/sparkql/function_resolver.rb, line 569
def now
  {
    type: :datetime,
    value: current_time.iso8601
  }
end
polygon(coords) click to toggle source
# File lib/sparkql/function_resolver.rb, line 655
def polygon(coords)
  new_coords = parse_coordinates(coords)
  unless new_coords.size > 2
    @errors << Sparkql::ParserError.new(token: coords,
                                        message: "Function call 'polygon' requires at least three coordinates",
                                        status: :fatal)
    return
  end

  # auto close the polygon if it's open
  new_coords << new_coords.first.clone unless new_coords.first == new_coords.last

  shape = GeoRuby::SimpleFeatures::Polygon.from_coordinates([new_coords])
  {
    type: :shape,
    value: shape
  }
end
radius(coords, length) click to toggle source
# File lib/sparkql/function_resolver.rb, line 725
def radius(coords, length)
  unless length.positive?
    @errors << Sparkql::ParserError.new(token: length,
                                        message: "Function call 'radius' length must be positive",
                                        status: :fatal)
    return
  end

  # The radius() function is overloaded to allow an identifier
  # to be specified over lat/lon.  This identifier should specify a
  # record that, in turn, references a lat/lon. Naturally, this won't be
  # validated here.
  shape_error = false
  shape = if coords?(coords)
            new_coords = parse_coordinates(coords)
            if new_coords.size != 1
              shape_error = true
            else
              GeoRuby::SimpleFeatures::Circle.from_coordinates(new_coords.first, length)
            end
          elsif Sparkql::Geo::RecordRadius.valid_record_id?(coords)
            Sparkql::Geo::RecordRadius.new(coords, length)
          else
            shape_error = true
          end

  if shape_error
    @errors << Sparkql::ParserError.new(token: coords,
                                        message: "Function call 'radius' requires one coordinate for the center",
                                        status: :fatal)
    return
  end

  {
    type: :shape,
    value: shape
  }
end
range(start_str, end_str) click to toggle source
# File lib/sparkql/function_resolver.rb, line 764
def range(start_str, end_str)
  {
    type: :character,
    value: [start_str.to_s, end_str.to_s]
  }
end
rectangle(coords) click to toggle source
# File lib/sparkql/function_resolver.rb, line 703
def rectangle(coords)
  bounding_box = parse_coordinates(coords)
  unless bounding_box.size == 2
    @errors << Sparkql::ParserError.new(token: coords,
                                        message: "Function call 'rectangle' requires two coordinates for the bounding box",
                                        status: :fatal)
    return
  end
  poly_coords = [
    bounding_box.first,
    [bounding_box.last.first, bounding_box.first.last],
    bounding_box.last,
    [bounding_box.first.first, bounding_box.last.last],
    bounding_box.first.clone
  ]
  shape = GeoRuby::SimpleFeatures::Polygon.from_coordinates([poly_coords])
  {
    type: :shape,
    value: shape
  }
end
regex(regular_expression, flags) click to toggle source

Supported function calls

# File lib/sparkql/function_resolver.rb, line 355
def regex(regular_expression, flags)
  unless (flags.chars.to_a - VALID_REGEX_FLAGS).empty?
    @errors << Sparkql::ParserError.new(token: regular_expression,
                                        message: 'Invalid Regexp',
                                        status: :fatal)
    return
  end

  begin
    Regexp.new(regular_expression)
  rescue StandardError
    @errors << Sparkql::ParserError.new(token: regular_expression,
                                        message: 'Invalid Regexp',
                                        status: :fatal)
    return
  end

  {
    type: :character,
    value: regular_expression
  }
end
round_decimal(arg) click to toggle source
# File lib/sparkql/function_resolver.rb, line 604
def round_decimal(arg)
  {
    type: :integer,
    value: arg.round.to_s
  }
end
seconds(num) click to toggle source

Offset the current timestamp by a number of seconds

# File lib/sparkql/function_resolver.rb, line 496
def seconds(num)
  t = current_time + num
  {
    type: :datetime,
    value: t.iso8601
  }
end
startswith(string) click to toggle source
# File lib/sparkql/function_resolver.rb, line 438
def startswith(string)
  # Wrap this string in quotes, as we effectively translate
  #   City Eq startswith('far')
  # ...to...
  #    City Eq '^far'
  #
  # The string passed in will merely be "far", rather than
  # the string literal "'far'".
  string = Regexp.escape(string)
  new_value = "^#{string}"

  {
    function_name: 'regex',
    function_parameters: [new_value, ''],
    type: :character,
    value: new_value
  }
end
substring_character(character, first_index, number_chars) click to toggle source
# File lib/sparkql/function_resolver.rb, line 385
def substring_character(character, first_index, number_chars)
  second_index = if number_chars.nil?
                   -1
                 else
                   number_chars + first_index - 1
                 end

  new_string = character[first_index..second_index].to_s

  {
    type: :character,
    value: new_string
  }
end
substring_index_error?(second_index) click to toggle source
# File lib/sparkql/function_resolver.rb, line 400
def substring_index_error?(second_index)
  if second_index.to_i.negative?
    @errors << Sparkql::ParserError.new(token: second_index,
                                        message: "Function call 'substring' may not have a negative integer for its second parameter",
                                        status: :fatal)
    true
  end
  false
end
time_datetime(datetime) click to toggle source
# File lib/sparkql/function_resolver.rb, line 632
def time_datetime(datetime)
  {
    type: :time,
    value: datetime.strftime(STRFTIME_TIME_FORMAT)
  }
end
tolower(_args) click to toggle source
# File lib/sparkql/function_resolver.rb, line 410
def tolower(_args)
  {
    type: :character,
    value: 'tolower'
  }
end
tolower_character(string) click to toggle source
# File lib/sparkql/function_resolver.rb, line 417
def tolower_character(string)
  {
    type: :character,
    value: "'#{string.to_s.downcase}'"
  }
end
toupper_character(string) click to toggle source
# File lib/sparkql/function_resolver.rb, line 424
def toupper_character(string)
  {
    type: :character,
    value: "'#{string.to_s.upcase}'"
  }
end
trim_character(arg) click to toggle source
# File lib/sparkql/function_resolver.rb, line 378
def trim_character(arg)
  {
    type: :character,
    value: arg.strip
  }
end
valid_cast_type?(type) click to toggle source
# File lib/sparkql/function_resolver.rb, line 786
def valid_cast_type?(type)
  if VALID_CAST_TYPES.key?(type.to_sym)
    true
  else
    @errors << Sparkql::ParserError.new(token: coords,
                                        message: "Function call 'cast' requires a castable type.",
                                        status: :fatal)
    false
  end
end
weekdays(number_of_days) click to toggle source
# File lib/sparkql/function_resolver.rb, line 533
def weekdays(number_of_days)
  today = current_date
  weekend_start = today.saturday? || today.sunday?
  direction = number_of_days.positive? ? 1 : -1
  weeks = (number_of_days / 5.0).to_i
  remaining = number_of_days.abs % 5

  # Jump ahead the number of weeks represented in the input
  today += weeks * 7

  # Now iterate on the remaining weekdays
  remaining.times do |i|
    today += direction
    while today.saturday? || today.sunday?
      today += direction
    end
  end

  # If we end on the weekend, bump accordingly
  while today.saturday? || today.sunday?
    # If we start and end on the weekend, wind things back to the next
    # appropriate weekday.
    if weekend_start && remaining == 0
      today -= direction
    else
      today += direction
    end
  end

  {
    type: :date,
    value: today.strftime(STRFTIME_DATE_FORMAT)
  }
end
wkt(wkt_string) click to toggle source
# File lib/sparkql/function_resolver.rb, line 690
def wkt(wkt_string)
  shape = GeoRuby::SimpleFeatures::Geometry.from_ewkt(wkt_string)
  {
    type: :shape,
    value: shape
  }
rescue GeoRuby::SimpleFeatures::EWKTFormatError
  @errors << Sparkql::ParserError.new(token: wkt_string,
                                      message: "Function call 'wkt' requires a valid wkt string",
                                      status: :fatal)
  nil
end
years(num_years) click to toggle source
# File lib/sparkql/function_resolver.rb, line 647
def years(num_years)
  d = current_timestamp >> (num_years * 12)
  {
    type: :date,
    value: d.strftime(STRFTIME_DATE_FORMAT)
  }
end

Private Instance Methods

coords?(coord_string) click to toggle source
# File lib/sparkql/function_resolver.rb, line 844
def coords?(coord_string)
  coord_string.split(' ').size > 1
end
parse_coordinates(coord_string) click to toggle source
# File lib/sparkql/function_resolver.rb, line 848
def parse_coordinates(coord_string)
  terms = coord_string.strip.split(',')
  terms.map do |term|
    term.strip.split(/\s+/).reverse.map(&:to_f)
  end
rescue StandardError
  @errors << Sparkql::ParserError.new(token: coord_string,
                                      message: 'Unable to parse coordinate string.',
                                      status: :fatal)
end