The Crystal Programming Language Forum

Wrapping a tuple and getting its types

With the neo4j shard, you can pass types to a query and it will yield a tuple of those types to a block to be destructured into individual arguments:

query = "MATCH (user:User)-[:MEMBER_OF]->(group) RETURN user, group"
txn.exec_cast query, {User, Group} do |(user, group)|
  # ...
end

But if you have a really complicated query that returns a lot of things (not super uncommon in graph queries), I want to be able to retrieve the returned values by name, so I’m trying to wrap the tuple in a Result type that will still allow tuple-like destructuring (for simpler queries) but also allow referencing by name. For example:

query = "MATCH (user:User)-[:MEMBER_OF]->(group) RETURN user, group"
txn.exec_cast query, {User, Group} do |result|
  result["user"] # User(@id=... name=...)
  result["group"] # Group(@id=... name=...)
end

The problem I’m coming across is that the type of result[0] (using numeric literal, not a variable) in this case is User | Group, not User. Here’s the code for Result (I’ve tried a few things, but this is where it stands right now):

struct Result(*T)
  getter values

  def initialize(@names : Array(String), @values : T)
  end

  def size
    @values.size
  end

  @[AlwaysInline]
  def [](index : Int)
    index += {{T.size}} if index < 0

    {% for i in 0...T.size %}
      return @values[{{i}}] if {{i}} == index
    {% end %}

    raise IndexError.new
  end

  @[AlwaysInline]
  def [](name : String)
    {% for i in 0...T.size %}
      return @values[{{i}}] if @names[{{i}}] == name
    {% end %}
    raise KeyError.new("No RETURN binding named #{name}, only #{@names.join(',')}")
  end
end

result = Result(User, Group).new(%w[user group], {user, group})
u, g = result
typeof(u) # => (Group | User)

I’m having a lot of trouble making it work like Tuple (despite trying to use almost identical code). It makes me wonder, does the compiler have special handling for tuples to make this work?

What about using a named tuple? You lose positional access, though. But then you can map a named tuple to a tuple if you really want that.

I considered that, but since you can’t generate NamedTuple types dynamically based on the query results (like the names of the variable bindings), you’d have to pass in the keys for the NamedTuple:

query = <<-QUERY
  MATCH (user:User)-[:MEMBER_OF]->(group)
  WHERE user.id = $user_id
  RETURN user, group
QUERY

transaction.exec_cast query, {user_id: 123}, {user: User, group: Group} do |result|
  result[:user] # User(...)
  result[:group] # Group(...)
end

I was trying to keep the redundancy down a bit by mapping the names we get back in the query result metadata (similar to the column metadata in a SQL query result set). Otherwise, you have the RETURN binding in the query, the NamedTuple passed into the query call, and the use of the NamedTuple results that all have to match up, which is 50% more places to keep track of result names than if I could just get the same kind of typed results that Tuples get. :-)

Then there’s no way to do what you want.

Is there more information you can provide on why? How does Tuple do it? I’ve tried using basically the same code Tuple does.

In the tuple case you are already passing a tuple type to say what type you want. You then have to access elements by index.

In the case of a named tuple it’s exactly the same: you provide the type that you want, which means mentioning the names and types, and then you access them by name. Is the part of specifying the named tuple type redundant to you? Or what’s the redundant part. There’s no way the compiler can know what type to associate with each name unless you tell it somewhere. It can be in the named tuple type.It can be when casting the union value to another type with as. But there’s no way to know this information at compile time from a runtime string. Or there is, by using macros and parsing the query. But that’s probably unnecessary complex.

You can have result[User], then do the casting to the type with as.

def [](object : O) forall O
 some_stuff.as O
end 

Yep, and I’m building that mapping of name to value here, including allowing it to fetch by numeric literal instead of by variable binding:

  def [](name : String)
    {% for i in 0...T.size %}
      return @values[{{i}}] if @names[{{i}}] == name
    {% end %}
    raise KeyError.new("No RETURN binding named #{name}, only #{@names.join(',')}")
  end

This is almost identical to how Tuple does it. Given this value:

result = Result(User, Group).new(
  names: %w[user group],
  values: {User.new, Group.new},
)

The following only differs in whether it gets the value from the wrapper object or the tuple, but it changes the type:

u, g = result.values
pp typeof(u) # => User
pp typeof(g) # => Group

u, g = result
pp typeof(u) # => (Group | User)
pp typeof(g) # => (Group | User)

Destructuring assignment from the tuple directly (u, g = result.values) gives me the types I expect. Destructured assignment from the wrapper, which just calls Result#[](index : Int), gives me the union of all the types. I’m trying to understand why it works for Tuple but not my code.

If you have tuple[n] where n is a number literal, the compiler will transform that to the element in that position. The compiler knows the types so it restricts the type too.

This is a special rule in the compiler for Tuple. A similar rule exists for NamedTuple.

There’s no way to implement such thing for other types.

With the above I mean, there’s nothing you can currently do. Could it be possible to somehow define a method like that in the future? Yes. But I doubt it will ever happen.