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
Public Class Methods
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
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
# File lib/sparkql/function_resolver.rb, line 295 def errors? @errors.size.positive? end
# 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
# File lib/sparkql/function_resolver.rb, line 299 def support SUPPORTED_FUNCTIONS end
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
# 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
# File lib/sparkql/function_resolver.rb, line 805 def cast_character(value, type) cast(value, type) end
# File lib/sparkql/function_resolver.rb, line 801 def cast_decimal(value, type) cast(value, type) end
# 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
# File lib/sparkql/function_resolver.rb, line 797 def cast_null(value, type) cast(value, type) end
# File lib/sparkql/function_resolver.rb, line 597 def ceiling_decimal(arg) { type: :integer, value: arg.ceil.to_s } end
# File lib/sparkql/function_resolver.rb, line 618 def concat_character(arg1, arg2) { type: :character, value: "'#{arg1}#{arg2}'" } end
# 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
# File lib/sparkql/function_resolver.rb, line 830 def current_date current_timestamp.to_date end
# File lib/sparkql/function_resolver.rb, line 834 def current_time current_timestamp.to_time end
# File lib/sparkql/function_resolver.rb, line 838 def current_timestamp @current_timestamp ||= DateTime.now end
# File lib/sparkql/function_resolver.rb, line 625 def date_datetime(datetime) { type: :date, value: datetime.strftime(STRFTIME_DATE_FORMAT) } end
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
# 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
# File lib/sparkql/function_resolver.rb, line 590 def floor_decimal(arg) { type: :integer, value: arg.floor.to_s } end
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
# File lib/sparkql/function_resolver.rb, line 611 def indexof(arg1, arg2) { value: 'indexof', args: [arg1, arg2] } end
# File lib/sparkql/function_resolver.rb, line 431 def length_character(string) { type: :integer, value: string.size.to_s } end
# 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
# File lib/sparkql/function_resolver.rb, line 576 def maxdatetime { type: :datetime, value: MAX_DATE_TIME } end
# File lib/sparkql/function_resolver.rb, line 583 def mindatetime { type: :datetime, value: MIN_DATE_TIME } end
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
# 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
The current timestamp
# File lib/sparkql/function_resolver.rb, line 569 def now { type: :datetime, value: current_time.iso8601 } end
# 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
# 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
# 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
# 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
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
# File lib/sparkql/function_resolver.rb, line 604 def round_decimal(arg) { type: :integer, value: arg.round.to_s } end
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
# 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
# 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
# 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
# File lib/sparkql/function_resolver.rb, line 632 def time_datetime(datetime) { type: :time, value: datetime.strftime(STRFTIME_TIME_FORMAT) } end
# File lib/sparkql/function_resolver.rb, line 410 def tolower(_args) { type: :character, value: 'tolower' } end
# File lib/sparkql/function_resolver.rb, line 417 def tolower_character(string) { type: :character, value: "'#{string.to_s.downcase}'" } end
# File lib/sparkql/function_resolver.rb, line 424 def toupper_character(string) { type: :character, value: "'#{string.to_s.upcase}'" } end
# File lib/sparkql/function_resolver.rb, line 378 def trim_character(arg) { type: :character, value: arg.strip } end
# 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
# 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
# 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
# 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
# File lib/sparkql/function_resolver.rb, line 844 def coords?(coord_string) coord_string.split(' ').size > 1 end
# 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