Need a way to pass Constructor arg-values into Macros (for Quill)

In Quill a Context is constructed by passing a SQL-dialect varialbe and a table/column Naming-Strategy variable into a context. These variables then need to be read by a macro that constructs an SQL query during compile time. Using Dotty macro-terminology, it works rougly like this (note that in the Scala 2 macros macroContext.prefix is used to look them up):

class Context[D <: Dialect, N <: NamingStrategy](dialectArg: D, namingArg: N) {
  inline def run(q: SqlQuery): Output = ${ runImpl('q) }
}
object Context {
  def runImpl(q: Expr[SqlQuery]): Expr[Output] = {
    val dialect: Dialect = getValueOf(dialectArg)
    val naming: NamingStrategy = getValueOf(namingArg)
    constructQueryWith(dialect, naming)
  }
}

@main def runQuery() = { new Context(PostgresDialect, SnakeCase).run(myQuery) }

The variables dialectArg and namingArg can be assumed to be static during compile time (Quill supports a runtime-option as well but that’s a secondary concern) in almost all cases they are objects.

One possible way to support this would be to allow inline constructor arguments and pass them into the macro:

class Context[D <: Dialect, N <: NamingStrategy](inline dialectArg: D, inline namingArg: N) {
  inline def run(q: SqlQuery): Output = ${ runImpl(dialectArg, namingArg) }
}

Then they can be looked up somehow, (maybe using ValueOf which I don’t entirely understand). As far as I know, in Dotty 0.21 you could pass an inline value into a macro directly (i.e. without the Expr part).

Another possible approach would be to have these arguments passed in via a static method that constructs a context (where inline arguments are possible). I do not like this approach very much because it would make it harder for users to extend contexts.

class Context[D <: Dialect, N <: NamingStrategy] {
  inline def dialectArg: Dialect = ???
  inline def namingArg: NamingStrategy = ???
  inline def run(q: SqlQuery): Output = ${ runImpl() }
}
object ContextMaker {
  inline def make[D <: Dialect, N <: NamingStrategy](inline D, inline N) =
    new Context [D, N]{
      override inline val dialectArg: D = d
      override inline val namingArg: N = n
    }
}

@main def runQuery() = { 
  ContextMaker.make(PostgresDialect, SnakeCase).run(myQuery)
}

I have not been able to get this approach to work due to compiler errors and the dialectArg/namingArg performing incorrectly when overridden. Should it be possible?

Edit:
I have posted an issue for the problems I encountered with using the ContextMaker.make approach here:

As explained in https://github.com/lampepfl/dotty/issues/8144 inline overrides will unfortunately not work for this.

We will need to use another approach. If I understand correctly, what you need is to have an instance of Dialect and NamingStrategy when expanding the macro. Is that right?

1 Like

This is correct. I have an instance of Dialect and NamingStrategy that needs to somehow be passed from the class’s constructors into the macro. I was thinking that if class constructor parameters could be inline, then I could just use ValueOfExpr to look them up but since constructor parameters cannot be inline, that is impossible.

I’ve figured out a hacky workaround for now using Java’s Class.forName for now so I am not blocked on the issue anymore.

Do we know all the possible instances of Dialect and NamingStrategy, or can those be extended by users? If so, we should be able to make it work ValueOfExpr, but it may need some tweaks in the ContextMaker.make API.

Otherwise, I have been looking into more general ways to evaluate the contents of trees. But this approach would be heavyweight and would require extra infrastructure that we currently do not have.

I think the following scheme would be really great for macro dependency injection/configuration by users:

  • A way of expressing “compile-time values” which are evaluated when the compilation unit is compiled.

  • A way to mark some macro parameters as compile-time, forcing users to provide expressions that can be evaluated at compile-time.

The only difference between a compile-time expression and a normal expression is that the former can only refer to other compile-time values. But runtime expressions can refer to both compile-time and runtime values.

// macro definition:
case class Config(foo: Boolean, bar: String)

def myMacro(e: Expr[Int], config: Config)

def foo(e: Int)(implicit @compileTime config: Config) =
  ${ myMacro('e, config) }

// in user code:
implicit @compileTime val c = Config(true, "hello")

foo(42)
println("BTW, this code was compiled with config: " + c)

foo(42)(Config(false, "bye"))

(Replace implicit by the implicits-redesign-syntax flavor of the day.)

It’s fine if the compile-time expressions are re-evaluated every time a compilation unit needs them. They will normally be pure (mainly for configuration purposes), which can be enforced later with an effect system. Alternatively, we could require them to be serializable and save their value for later runs.

This scheme is similar to what I had implemented in Squid and is pretty useful (though my implementation used a type class for this purpose).

2 Likes