Search code examples
crystal-lang

How to handle redundant types in Crystal?


I'm using the crystal language, and it's been great so far. Unfortunately, I feel like my code is becoming a bit too messy with types everywhere.

For example:

  # ---------=====----++---
  # Grab characters
  # ---------=====----++---
  def handle_character_list(msg, client)
    result = {} of String => Array(Tuple(Int64, String, String, Int16, Int8)) | Int32 | Int16 | Int64 | String

    result["characters"] = db.query_all "select character_id, character_name, DATE_FORMAT(created, '%b:%d:%Y:%l:%i:%p') AS created, level, cc from rpg_characters where user_id = ? ", client.user_id,
      as: {Int64, String, String, Int16, Int8}

    result["max_char_slots"] = client.user_data["max_char_slots"]

    puts result
  end

While looking at the db.query_all method, it says:

returns an array where the value of each row is read as the given type

With the aforementioned above, why do I need to explicitly set those types again to my result variable, if they are going to be returned?

I feel like I'm doing something wrong, and any advice/insight is appreciated.


Solution

  • The first thing that jumps out at me is the size of the type of your hash. You seem to be using Hash the same way as in Ruby. Don't.

    In Ruby, or other dynamic languages, Hashes or objects are used as generic data containers, almost like unnamed classes. In Crystal, hashes have a single type for the key, and a single type for the value, which makes them unsuited for the task. You want to tell Crystal more about the structure of your data, so you don't have to keep repeating that.

    The first thing to do is look at the result object. It can simply be transformed into a record Result:

    record Result,
      characters: Array({Int64, String, String, Int16, Int8}),
      max_char_slots: Int32
    

    the method then becomes

    def handle_character_list(msg, client)
      sql = <<-SQL
        SELECT character_id, character_name, DATE_FORMAT(created, '%b:%d:%Y:%l:%i:%p') AS created, level, cc
        FROM rpg_characters
        WHERE user_id = ?
        SQL
      characters = db.query_all sql, client.user_id, as: {Int64, String, String, Int16, Int8}
    
      max_char_slots = client.user_data["max_char_slots"]
    
      Result.new(characters, max_char_slots)
    end
    

    However, by looking at the method it might be that this Result record is only used in one place - to return data from this method. In that case it's unlikely you want to give it a more formal name. In this case you could use a NamedTuple. They're a bit like an anonymous record.

    def handle_character_list(msg, client)
      sql = <<-SQL
        SELECT character_id, character_name, DATE_FORMAT(created, '%b:%d:%Y:%l:%i:%p') AS created, level, cc
        FROM rpg_characters
        WHERE user_id = ?
        SQL
    
      {
        characters: db.query_all(sql, client.user_id, as: {Int64, String, String, Int16, Int8}),
        max_char_slots: client.user_data["max_char_slots"]
      }
    end
    

    Going further, we can see that a "Character" is also a type:

    class Character
      getter id : Int64
      getter name : String
      getter created : Time
      getter level : Int16
      getter cc : Int8
    
      def initialize(@id, @name, @created, @level, @cc)
      end
    end
    

    We can then use the DB.mapping to define how the Character class looks in the database.

    class Character
      DB.mapping({
        id: Int64,
        name: String.
        created: Time,
        level: Int16,
        cc: Int8
      })
    
      def initialize(@id, @name, @created, @level, @cc)
      end
    end
    

    This definition is equivalent to the previous one because DB.mapping automatically generates getters for us.

    def handle_character_list(msg, client)
      sql = <<-SQL
        SELECT character_id, character_name, created, level, cc
        FROM rpg_characters
        WHERE user_id = ?
        SQL
    
      {
        characters: db.query_all(sql, client.user_id, as: Character),
        max_char_slots: client.user_data["max_char_slots"]
      }
    end
    

    Going even further, I'd extract that into two methods, each one doing just one thing, and I'd probably make client.user_data more type safe:

    def characters_for_user(user_id)
      sql = <<-SQL
        SELECT character_id, character_name, created, level, cc
        FROM rpg_characters
        WHERE user_id = ?
        SQL
      db.query_all(sql, user_id, as: Character)
    end
    
    def handle_character_list(msg, client)
      {
        characters: characters_for_user(client.user_id),
        max_character_slots: client.user_data.max_char_slots
      }
    end
    

    This is just my thought process on how I'd write the code you've shown. I've made a lot of assumptions about your code and database which might be wrong (i.e. "created" is a DATETIME in mysql). I'm attempting to show a thought process, not a finished solution. Hope that helps.