Absinthe 1.5 动态定义枚举值

absinthe graphql enum elixir macro


Absinthe 是 Elixir 语言的一个 GraphQL 工具库。即将发布的 1.5 版本经过了大量重构,该版本将不再支持动态定义 enum 类型的值。详情可以前往以下链接进一步了解。

https://github.com/absinthe-graphql/absinthe/issues/843
https://github.com/absinthe-graphql/absinthe/pull/859

hydrate

为了替代原来动态定义的枚举值,1.5 版本中引入了 hydrate/2 ,请求时将会回调 hydrate/2 以处理枚举值的映射关系。
举个例子,假如邮箱的激活状态是由 MyApp.Email 中的两个函数进行定义

defmodule MyApp.Email do
  def inactivated, do: 1
  def activated, do: 2
end

那么 1.5 版本之前你可以这样定义这个枚举

alias MyApp.Email

enum :email_status do
  value(:inactivated, as: Email.inactivated())
  value(:activated, as: Email.activated())
end

但是在 1.5 版本中这么定义就会报错了,因为 as 之后不再支持函数调用,想要实现类似的效果就得用 hydrate/2

alias MyApp.Email

enum :email_status do
  value(:inactivated)
  value(:activated)
end

def hydrate(
  %Absinthe.Blueprint.Schema.EnumValueDefinition{identifier: identifier},
  [%Absinthe.Blueprint.Schema.EnumTypeDefinition{identifier: :email_status}]
) do
  value =
    case identifier do
      :inactivated -> Email.inactivated()
      :activated -> Email.activated()
    end

  {:as, value}
end

🤨 实在是有点难受。

编写 enum_dynamic 宏

不过不使用 hydrate/2 ,直接硬编码还是可以的

enum :email_status do
  value(:inactivated, as: 1)
  value(:activated, as: 2)
end

但是这样会导致硬编码出现在两个地方,更让人难受了。
这个时候,就轮到强大的 Macro 登场!在编译期将函数调用替换为硬编码,以实现用最小的改动来兼容新版本。
先来设想这个宏的用法,设计的核心思路是尽量减少需要修改的代码

+ import MyApp.Schema.Helper.Enum
  alias MyApp.Email

- enum :email_status do
+ enum_dynamic :email_status do
    value(:inactivated, as: Email.inactivated())
    value(:activated, as: Email.activated())
  end

这样的话只须把所有的 enum 改名即可,很方便进行升级,接下来分析怎么去实现这个宏。
借助 Elixir 提供的 Macro.prewalk/2Macro.postwalk/2 函数,就可以可以前序或者后续遍历 AST 并对 AST 中的节点进行修改,还是非常方便的。
在动手实现之前,先来用 quote 看看修改前的 AST 与修改后的 AST 有什么区别

这是修改前的

quote do
  enum_dynamic :email_status do
    value(:inactivated, as: Email.inactivated())
    value(:activated, as: Email.activated())
  end
end
{:enum_dynamic, [],
 [
   :email_status,
   [
     do: {:__block__, [],
      [
        {:value, [],
         [
           :inactivated,
           [
             as: {{:., [],
               [{:__aliases__, [alias: false], [:Email]}, :inactivated]}, [],
              []}
           ]
         ]},
        {:value, [],
         [
           :activated,
           [
             as: {{:., [],
               [{:__aliases__, [alias: false], [:Email]}, :activated]}, [], []}
           ]
         ]}
      ]}
   ]
 ]}

我们希望修改后是这样一个结果

quote do
  enum :email_status do
    value(:inactivated, as: 1)
    value(:activated, as: 2)
  end
end
{:enum, [],
 [
   :email_status,
   [
     do: {:__block__, [],
      [
        {:value, [], [:inactivated, [as: 1]]},
        {:value, [], [:activated, [as: 2]]}
      ]}
   ]
 ]}

对比可以看出来就是把 :as 后的内容给它执行了,并且把 :enum_dynamic 再换回 :enum
那么具体实现就可以这么写

# :email_status 会传给 name
# do block 会传给 values
defmacro enum_dynamic(name, do: values) do
  values = Macro.postwalk(values, fn
    {:as, value} ->
      # 找到 :as 开头的 AST 节点,把这个节点修改为执行后的值
      {actual_value, _} = Code.eval_quoted(value)
      {:as, actual_value}
    node ->
      # 其余 AST node 保持不变
      node
  end)

  {:enum, [], [name, [do: values]]}
end

但是仅仅这样写你会发现,这个宏无法处理 alias 模块名的调用,所以这里需要指定运行环境为 __CALLER__ 以展开 alias
整理过后就是这样

defmacro enum_dynamic(name, do: values) do
  eval_value = fn
    {:as, value} ->
      {actual_value, _} = Code.eval_quoted(value, [], __CALLER__)
      {:as, actual_value}

    node ->
      node
  end

  values = Macro.postwalk(values, eval_value)

  {:enum, [], [name, [do: values]]}
end

注意事项

宏是在编译期执行的,如果 as 后的函数在编译期无法执行,或者编译期的执行结果与运行时不同,使用 enum_dynamic 就会出现问题。

推荐阅读更多精彩内容