# The MIT icense (MIT) # Copyright (c) 2023 Mike DeAngelo Google, Inc. # Permission is hereby granted, free of charge, to any person obtaining a copy of # this software and associated documentation files (the "Software"), to deal in # the Software without restriction, including without limitation the rights to # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of # the Software, and to permit persons to whom the Software is furnished to do so, # subject to the following conditions: # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS # FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR # COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER # IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. # frozen_string_literal: true require 'forwardable' require 'tty-reader' require 'netrc' require 'looker-sdk' require 'faraday/multipart' require_relative 'modules/session' module Gzr class Command extend Forwardable include Gzr::Session def initialize @sdk = nil @access_token_stack = Array.new @options = Hash.new end def_delegators :command, :run # Execute this command # # @api public def execute(*) raise( NotImplementedError, "#{self.class}##{__method__} must be implemented" ) end def get_user_by_id(user_id, req=nil) begin @sdk.user(user_id, req).to_attrs rescue LookerSDK::NotFound => e nil rescue LookerSDK::Error => e say_error "Error querying get_user_by_id(#{user_id})" say_error e raise end end def get_auth() begin return @sdk.session()&.to_attrs rescue LookerSDK::Error => e say_error "Unable to run session()" say_error e end end def query(query_id) data = nil begin data = @sdk.query(query_id) rescue LookerSDK::Error => e say_error "Error querying query(#{query_id})" say_error e raise end data end def create_query(query) begin data = @sdk.create_query(query) if !(data.respond_to?(:id)) raise Gzr::CLI::Error, "create_query(#{JSON.pretty_generate(query)}) returned #{data.inspect}" end rescue LookerSDK::Error => e say_error "Error creating query(#{JSON.pretty_generate(query)})" say_error e raise end data end def merge_query(merge_result_id) data = nil begin data = @sdk.merge_query(merge_result_id) rescue NoMethodError => nme say_error "The api endpoint merge_query(#{merge_result_id}) is not implemented on this Looker instance" rescue LookerSDK::Error => e say_error "Error querying merge_query(#{merge_result_id})" say_error e raise end data end def create_merge_query(merge_query) begin data = @sdk.create_merge_query(merge_query) rescue NoMethodError => nme say_error "The api endpoint create_merge_query() is not implemented on this Looker instance" raise rescue LookerSDK::Error => e say_error "Error creating merge_query(#{JSON.pretty_generate(merge_query)})" say_error e raise end data end def run_inline_query(query) begin @sdk.run_inline_query("json",query).collect { |r| r.to_attrs } rescue LookerSDK::Error => e say_error "Error running inline_query(#{JSON.pretty_generate(query)})" say_error e raise end end def all_color_collections() data = nil begin data = @sdk.all_color_collections().collect { |o| o.to_attrs if o.respond_to?(:to_attrs) } rescue NoMethodError => nme say_warning "The api endpoint all_color_collections() is not implemented on this Looker instance" rescue LookerSDK::NotFound => nf say_warning "The current user can't query all color collections" rescue LookerSDK::Error => e say_error "Error querying all_color_collections()" say_error e raise end data end def default_color_collection() return @dcc if @dcc data = nil begin data = @sdk.default_color_collection().to_attrs @dcc = data rescue NoMethodError => nme say_warning "The api endpoint default_color_collection() is not implemented on this Looker instance" rescue LookerSDK::NotFound => nf say_warning "The current user can't query the default color collection" rescue LookerSDK::Error => e say_error "Error querying default_color_collection()" say_error e raise end data end def color_collection(collection_id) data = nil begin data = @sdk.color_collection(collection_id).to_attrs rescue NoMethodError => nme say_warning "The api endpoint color_collection(collection_id) is not implemented on this Looker instance" rescue LookerSDK::NotFound => nf say_warning "The color_collection(#{collection_id}) is not found" rescue LookerSDK::Error => e say_error "Error querying color_collection(#{collection_id})" say_error e raise end data end def find_vis_config_reference(obj, &block) if obj.respond_to?(:'has_key?') && obj.has_key?(:vis_config) yield (obj[:vis_config]) elsif obj.is_a? Enumerable obj.each { |o| find_vis_config_reference(o,&block) } end end def find_color_palette_reference(obj, default_colors=nil, &block) begin dcc = default_color_collection() if dcc.nil? say_warning "You do not have access to query color palettes so these won't be processed." return end @default_colors=color_palette_lookup!(dcc) #say_warning("Default colors #{JSON.pretty_generate @default_colors}") if @options[:debug] end unless @default_colors if obj.respond_to?(:'has_key?') && obj.has_key?(:collection_id) && obj.has_key?(:palette_id) yield(obj,@default_colors) elsif obj.is_a? Enumerable obj.each { |o| find_color_palette_reference(o,@default_colors,&block) } end end def color_palette_lookup!(obj) return nil unless obj.respond_to?(:'has_key?') #say_warning("performing color_palette_lookup! on #{JSON.pretty_generate obj}") if @options[:debug] palettes = [] palettes += obj[:categoricalPalettes] if obj[:categoricalPalettes] palettes += obj[:sequentialPalettes] if obj[:sequentialPalettes] palettes += obj[:divergingPalettes] if obj[:divergingPalettes] obj[:palettes]=palettes #say_warning("got palettes #{JSON.pretty_generate palettes}") if @options[:debug] obj end def rewrite_color_palette!(o,default_colors) cc = nil if o[:collection_id] == default_colors[:id] o[:collection_default] = true cc = default_colors else o[:collection_default] = false #say_ok("looking up color collection by id #{o[:collection_id]}") if @options[:debug] cc = color_palette_lookup!(color_collection(o[:collection_id])) end return unless cc #say_warning("got color collection #{JSON.pretty_generate cc}") if @options[:debug] o[:collection_label] = cc[:label] ps = cc[:palettes].select { |p| p[:id] == o[:palette_id] } if ps.length > 0 p = ps.first o[:palette_label] = p[:label] o[:palette_type] = p[:type] o[:palette_colors] = p[:colors] if p[:colors] o[:palette_stops] = p[:stops] if p[:stops] end end def update_color_palette!(o,default_colors,force_default=false) return unless o.has_key?(:collection_label) && o.has_key?(:palette_type) cc = default_colors if !(force_default && o[:collection_default]) # look up color collection by id #say_warning("attempting to match palette on id #{o[:collection_id]}") if @options[:debug] cc = color_palette_lookup!(color_collection(o[:collection_id])) if cc.nil? # find color collection by name #say_warning("no match on id, attempting to match palette on name #{o[:collection_label]}") if @options[:debug] ccs = all_color_collections()&.select { |cc| o[:collection_label] == cc[:label]} if ccs.nil? || ccs.length == 0 # no color collection found. Use default. say_warning "Color collection #{o[:collection_label]} not found. Using default." cc = default_colors else cc = color_palette_lookup!(ccs.first) end end end o[:collection_id] = cc[:id] # look up palette by id ps = cc[:palettes].select {|p| p[:id] == o[:palette_id]} if ps.length == 0 # find palette by type ps = cc[:palettes].select {|p| p[:type] == o[:palette_type]} if ps.length > 0 o[:palette_id] = ps.first[:id] else # no palette found say_warning "Color palette #{o[:palette_type]} not found." o.delete(:palette_id) end end end ## # This method accepts the name of an sdk operation, then finds the parameter for that # operation in the data structures from the swagger.json file. The parameter is a # json object. Some of the attributes of the json object are read-only, and some # are read-write. A few are write-only. The list of read-write and write-only attribute # names are returned as an array. That array can be used to take the json document that # describes an object and strip out the read-only values, creating a document that can # be used to create or update an object. # # The pattern typically looks like this... # # new_obj_hash = existing_obj_hash.select do |k,v| # keys_to_keep('create_new_obj').include? k # end def keys_to_keep(operation) @keys_to_keep_cache ||= {} @keys_to_keep_cache[operation] ||= keys_to_keep_internal(operation) end def keys_to_keep_internal(operation) o = @sdk.operations[operation] || @sdk.operations[operation.to_sym] begin say_error "Operation #{operation} not found" return [] end unless o parameters = o[:info][:parameters].select { |p| p[:in] == "body" && p[:schema] } say_warning "Expecting exactly one body parameter with a schema for operation #{operation}" unless parameters.length == 1 schema_ref = parameters[0][:schema][:$ref].split(/\//) key_list = @sdk.swagger[schema_ref[1].to_sym][schema_ref[2].to_sym][:properties].reject { |k,v| v[:readOnly] }.keys say_warning "key_list #{operation} -> #{key_list}" if @options[:debug] return key_list end ## # The tty-table gem is normally used to output tabular data. This method accepts a Table # object as used by the tty-table gem, and generates CSV output. It returns a string # with crlf encoding def render_csv(t) io = StringIO.new io.puts ( t.header.collect do |v| v ? "\"#{v.to_s.gsub(/"/, '""')}\"" : "" end.join(',') ) unless @options[:plain] t.each do |row| next if row === t.header io.puts ( row.collect do |v| v ? "\"#{v.to_s.gsub(/"/, '""')}\"" : "" end.join(',') ) end io.rewind io.gets(nil).encode(crlf_newline: true) end ## # This method accepts a string containing a list of fields. The fields can be nested # in a format like... # # 'a,b,c(d,e(f,g)),h' # # representing a structure like # # { # a: "val", # b: "val", # c: { # d: "val", # e: { # f: "val", # g: "val" # } # }, # h: "val" # } # # That string will get parsed and yield an array like # [ a, b, c.d, c.e.f, c.e.g, h ] # def field_names(opt_fields) fields = [] token_stack = [] last_token = false tokens = opt_fields.split /(\(|,|\))/ tokens << nil tokens.each do |t| if t.nil? then fields << (token_stack + [last_token]).join('.') if last_token elsif t.empty? then next elsif t == ',' then fields << (token_stack + [last_token]).join('.') if last_token elsif t == '(' then token_stack.push(last_token) elsif t == ')' then fields << (token_stack + [last_token]).join('.') if last_token token_stack.pop last_token = false else last_token = t end end fields end ## # This method will accept a field name in a format like 'c.e.g' # and convert it into 'c&.e&.g', which can be evaluated to get # the value of g, or nil if any intermediate value is nil. def field_expression(name) parts = name.split(/\./) parts.join('&.') end def field_expression_hash(name) parts = name.split(/\./) parts.collect { |p| "&.fetch(:#{p},nil)" }.join('') end # This version of field names yields an expression that can be evaluated against a hash structure # like this one... # # data&.fetch(:a,nil) # val1 # data&.fetch(:b,nil) # val2 # data&.fetch(:c,nil)&.fetch(:d,nil) # val3 # data&.fetch(:c,nil)&.fetch(:e,nil)&.fetch(:f,nil) # val4 # data&.fetch(:c,nil)&.fetch(:e,nil)&.fetch(:g,nil) # val5 # data&.fetch(:h,nil) # val6 # # data = # { # a: "val", # b: "val", # c: { # d: "val", # e: { # f: "val", # g: "val" # } # }, # h: "val" # } # # field_names_hash(fields).each do |field| # puts "data#{field}" # puts eval "data#{field}" # end def field_names_hash(opt_fields) fields = [] token_stack = [] last_token = false tokens = opt_fields.split /(\(|,|\))/ tokens << nil tokens.each do |t| if t == '(' then token_stack.push(last_token) elsif t.nil? || t == ',' then fields << "&.fetch(:#{(token_stack + [last_token]).join(',nil)&.fetch(:')},nil)" if last_token elsif t.empty? then next elsif t == ')' then fields << "&.fetch(:#{(token_stack + [last_token]).join(',nil)&.fetch(:')},nil)" if last_token token_stack.pop last_token = false else last_token = t end end fields end ## # This method will accept two arrays, a and b, and create a third array # like [ [a[0],b[0]], [a[1],b[1]], [a[2],b[2]], ...]. # If either array is longer than the other, additional pairs # will be generated with the shorter array padded out with nil values. # # Any additional args will be added to each inner array. def pairs(a, b, *args) pair_array = Array.new([a.count,b.count].max) do |i| pair = [a.fetch(i,nil),b.fetch(i,nil)] pair += args if args pair end return pair_array unless block_given? pair_array.collect { |e| yield(e) } end end end