Rework of parameter system (server-side)
This commit is contained in:
parent
198c64d86a
commit
9d41222dc7
245
plugin.rb
245
plugin.rb
|
@ -40,25 +40,6 @@ after_initialize do
|
|||
class ValidationError < StandardError;
|
||||
end
|
||||
|
||||
# Extract :colon-style parameters from the SQL query and replace them with
|
||||
# $1-style parameters.
|
||||
#
|
||||
# @return [Hash] :sql => [String] the new SQL query to run, :names =>
|
||||
# [Array] the names of all parameters, in order by their $-style name.
|
||||
# (The first name is $0.)
|
||||
def self.extract_params(sql)
|
||||
names = []
|
||||
new_sql = sql.gsub(/(:?):([a-z_]+)/) do |_|
|
||||
if $1 == ':' # skip casts
|
||||
$&
|
||||
else
|
||||
names << $2
|
||||
"$#{names.length - 1}"
|
||||
end
|
||||
end
|
||||
{sql: new_sql, names: names}
|
||||
end
|
||||
|
||||
# Run a data explorer query on the currently connected database.
|
||||
#
|
||||
# @param [DataExplorer::Query] query the Query object to run
|
||||
|
@ -71,21 +52,20 @@ after_initialize do
|
|||
# pg_result - the PG::Result object
|
||||
# duration_nanos - the query duration, in nanoseconds
|
||||
# explain - the query
|
||||
def self.run_query(query, params={}, opts={})
|
||||
def self.run_query(query, req_params={}, opts={})
|
||||
# Safety checks
|
||||
if query.sql =~ /;/
|
||||
err = DataExplorer::ValidationError.new(I18n.t('js.errors.explorer.no_semicolons'))
|
||||
return {error: err, duration_nanos: 0}
|
||||
end
|
||||
|
||||
query_args = (query.qopts[:defaults] || {}).with_indifferent_access.merge(params)
|
||||
|
||||
# Rudimentary types
|
||||
query_args.each do |k, arg|
|
||||
if arg =~ /\A\d+\z/
|
||||
query_args[k] = arg.to_i
|
||||
end
|
||||
query_args = {}
|
||||
begin
|
||||
query_args = query.cast_params req_params
|
||||
rescue DataExplorer::ValidationError => e
|
||||
return {error: e, duration_nanos: 0}
|
||||
end
|
||||
|
||||
# If we don't include this, then queries with a % sign in them fail
|
||||
# because AR thinks we want percent-based parametes
|
||||
query_args[:xxdummy] = 1
|
||||
|
@ -277,18 +257,11 @@ SQL
|
|||
# Reimplement a couple ActiveRecord methods, but use PluginStore for storage instead
|
||||
class DataExplorer::Query
|
||||
attr_accessor :id, :name, :description, :sql
|
||||
attr_reader :qopts
|
||||
|
||||
def initialize
|
||||
@name = 'Unnamed Query'
|
||||
@description = 'Enter a description here'
|
||||
@sql = 'SELECT 1'
|
||||
@qopts = {}
|
||||
end
|
||||
|
||||
def param_names
|
||||
param_info = DataExplorer.extract_params sql
|
||||
param_info[:names]
|
||||
end
|
||||
|
||||
def slug
|
||||
|
@ -297,6 +270,23 @@ SQL
|
|||
s
|
||||
end
|
||||
|
||||
def params
|
||||
@params ||= DataExplorer::Parameter.create_from_sql(sql)
|
||||
end
|
||||
|
||||
def check_params!
|
||||
params
|
||||
nil
|
||||
end
|
||||
|
||||
def cast_params(input_params)
|
||||
result = {}.with_indifferent_access
|
||||
self.params.each do |pobj|
|
||||
result[pobj.identifier] = pobj.cast_to_ruby input_params[pobj.identifier]
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
# saving/loading functions
|
||||
# May want to extract this into a library or something for plugins to use?
|
||||
def self.alloc_id
|
||||
|
@ -308,22 +298,9 @@ SQL
|
|||
end
|
||||
end
|
||||
|
||||
def qopts=(val)
|
||||
case val
|
||||
when String
|
||||
@qopts = HashWithIndifferentAccess.new(MultiJson.load(val))
|
||||
when HashWithIndifferentAccess
|
||||
@qopts = val
|
||||
when Hash
|
||||
@qopts = val.with_indifferent_access
|
||||
else
|
||||
raise ArgumentError.new('invalid type for qopts')
|
||||
end
|
||||
end
|
||||
|
||||
def self.from_hash(h)
|
||||
query = DataExplorer::Query.new
|
||||
[:name, :description, :sql, :qopts].each do |sym|
|
||||
[:name, :description, :sql].each do |sym|
|
||||
query.send("#{sym}=", h[sym]) if h[sym]
|
||||
end
|
||||
if h[:id]
|
||||
|
@ -338,7 +315,6 @@ SQL
|
|||
name: @name,
|
||||
description: @description,
|
||||
sql: @sql,
|
||||
qopts: @qopts.to_hash,
|
||||
}
|
||||
end
|
||||
|
||||
|
@ -352,6 +328,7 @@ SQL
|
|||
end
|
||||
|
||||
def save
|
||||
check_params!
|
||||
unless @id && @id > 0
|
||||
@id = self.class.alloc_id
|
||||
end
|
||||
|
@ -376,6 +353,164 @@ SQL
|
|||
end
|
||||
end
|
||||
|
||||
class DataExplorer::Parameter
|
||||
attr_accessor :identifier, :type, :default, :nullable
|
||||
|
||||
def initialize(identifier, type, default, nullable)
|
||||
raise DataExplorer::ValidationError.new('Parameter declaration error - identifier is missing') unless identifier
|
||||
raise DataExplorer::ValidationError.new('Parameter declaration error - type is missing') unless type
|
||||
# process aliases
|
||||
type = type.to_sym
|
||||
if DataExplorer::Parameter.type_aliases[type]
|
||||
type = DataExplorer::Parameter.type_aliases[type]
|
||||
end
|
||||
raise DataExplorer::ValidationError.new("Parameter declaration error - unknown type #{type}") unless DataExplorer::Parameter.types[type]
|
||||
|
||||
@identifier = identifier
|
||||
@type = type
|
||||
@default = default
|
||||
@nullable = nullable
|
||||
begin
|
||||
cast_to_ruby default unless default.blank?
|
||||
rescue DataExplorer::ValidationError
|
||||
raise DataExplorer::ValidationError.new("Parameter declaration error - the default value is not a valid #{type}")
|
||||
end
|
||||
end
|
||||
|
||||
def to_hash
|
||||
{
|
||||
identifier: @identifier,
|
||||
type: @type,
|
||||
default: @default,
|
||||
nullable: @nullable,
|
||||
}
|
||||
end
|
||||
|
||||
def self.types
|
||||
@types ||= Enum.new(
|
||||
# Normal types
|
||||
:int, :bigint, :boolean, :string, :time, :double,
|
||||
# Selection help
|
||||
:user_id, :post_id, :topic_id, :category_id, :group_id, :badge_id,
|
||||
# Arrays
|
||||
:int_list, :string_list
|
||||
)
|
||||
end
|
||||
|
||||
def self.type_aliases
|
||||
@type_aliases ||= {
|
||||
integer: :int,
|
||||
text: :string,
|
||||
}
|
||||
end
|
||||
|
||||
def cast_to_ruby(string)
|
||||
string = @default unless string
|
||||
|
||||
if string.blank?
|
||||
if @nullable
|
||||
return nil
|
||||
else
|
||||
raise DataExplorer::ValidationError.new("Missing parameter #{identifier} of type #{type}")
|
||||
end
|
||||
end
|
||||
if string.downcase == '#null'
|
||||
return nil
|
||||
end
|
||||
|
||||
def invalid_format(string, msg=nil)
|
||||
if msg
|
||||
raise DataExplorer::ValidationError.new("'#{string}' is an invalid #{type} - #{msg}")
|
||||
else
|
||||
raise DataExplorer::ValidationError.new("'#{string}' is an invalid value for #{type}")
|
||||
end
|
||||
end
|
||||
|
||||
value = nil
|
||||
|
||||
case @type
|
||||
when :int
|
||||
value = string.to_i
|
||||
invalid_format string, 'Too large' unless Fixnum === value
|
||||
when :bigint
|
||||
value = string.to_i
|
||||
when :boolean
|
||||
value = !!(string =~ /t|true|y|yes|1/)
|
||||
when :string
|
||||
value = string
|
||||
when :time
|
||||
begin
|
||||
value = Time.parse string
|
||||
rescue ArgumentError => e
|
||||
invalid_format string, e.message
|
||||
end
|
||||
when :double
|
||||
value = string.to_f
|
||||
when :user_id, :post_id, :topic_id, :category_id, :group_id, :badge_id
|
||||
pkey = string.to_i
|
||||
if pkey != 0
|
||||
clazz_name = (/^(.*)_id$/.match(type.to_s)[1].classify.to_sym)
|
||||
begin
|
||||
Object.const_get(clazz_name).find(pkey)
|
||||
value = pkey
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
invalid_format string, "The specified #{clazz_name} was not found"
|
||||
end
|
||||
else
|
||||
invalid_format string
|
||||
end
|
||||
when :int_list
|
||||
value = string.split(',').map(&:to_i)
|
||||
invalid_format string, "can't be empty" if value.length == 0
|
||||
when :string_list
|
||||
value = string.split(',')
|
||||
invalid_format string, "can't be empty" if value.length == 0
|
||||
else
|
||||
raise TypeError.new('unknown parameter type??? should not get here')
|
||||
end
|
||||
|
||||
value
|
||||
end
|
||||
|
||||
def self.create_from_sql(sql)
|
||||
in_params = false
|
||||
ret_params = []
|
||||
sql.split("\n").find do |line|
|
||||
if in_params
|
||||
# -- (ident) :(ident) (= (ident))?
|
||||
|
||||
if line =~ /^\s*--\s*([a-zA-Z_ ]+)\s*:([a-z_]+)\s*(?:=\s+(.*)\s*)?$/
|
||||
type = $1
|
||||
ident = $2
|
||||
default = $3
|
||||
nullable = false
|
||||
if type =~ /^(null)?(.*?)(null)?$/i
|
||||
if $1 or $3
|
||||
nullable = true
|
||||
end
|
||||
type = $2
|
||||
end
|
||||
type = type.strip
|
||||
|
||||
ret_params << DataExplorer::Parameter.new(ident, type, default, nullable)
|
||||
false
|
||||
elsif line =~ /^\s+$/
|
||||
false
|
||||
else
|
||||
true
|
||||
end
|
||||
|
||||
else
|
||||
if line =~ /^\s*--\s*\[params\]\s*$/
|
||||
in_params = true
|
||||
end
|
||||
false
|
||||
end
|
||||
end
|
||||
return ret_params
|
||||
end
|
||||
end
|
||||
|
||||
require_dependency 'application_controller'
|
||||
class DataExplorer::QueryController < ::ApplicationController
|
||||
requires_plugin DataExplorer.plugin_name
|
||||
|
@ -430,12 +565,15 @@ SQL
|
|||
end
|
||||
end
|
||||
|
||||
[:name, :sql, :description, :qopts].each do |sym|
|
||||
[:name, :sql, :description].each do |sym|
|
||||
query.send("#{sym}=", hash[sym]) if hash[sym]
|
||||
end
|
||||
query.check_params!
|
||||
query.save
|
||||
|
||||
render_serialized query, DataExplorer::QuerySerializer, root: 'query'
|
||||
rescue DataExplorer::ValidationError => e
|
||||
render_json_error e.message
|
||||
end
|
||||
|
||||
def destroy
|
||||
|
@ -520,7 +658,11 @@ SQL
|
|||
end
|
||||
|
||||
class DataExplorer::QuerySerializer < ActiveModel::Serializer
|
||||
attributes :id, :sql, :name, :description, :qopts, :param_names
|
||||
attributes :id, :sql, :name, :description, :param_info
|
||||
|
||||
def param_info
|
||||
object.params.map(&:to_hash) rescue nil
|
||||
end
|
||||
end
|
||||
|
||||
DataExplorer::Engine.routes.draw do
|
||||
|
@ -531,7 +673,6 @@ SQL
|
|||
get 'queries/:id' => "query#show"
|
||||
put 'queries/:id' => "query#update"
|
||||
delete 'queries/:id' => "query#destroy"
|
||||
get 'queries/parse_params' => "query#parse_params"
|
||||
post 'queries/:id/run' => "query#run"
|
||||
end
|
||||
|
||||
|
|
Loading…
Reference in New Issue