Option TryFrom 매크로 확장 계획 (매크로·제네릭, GlueSQL #1389)

NOTE

아직 착수 전 — 이번 주 일요일 작업 예정. 2024년에 열린 오래된 이슈라 재현 여부를 가장 먼저 확인해야 함(아래 “확인할 것” 참고). 셋 중 가장 개념적으로 흥미로운 케이스 — “당연히 될 것 같은” 제네릭 blanket impl이 왜 컴파일 에러가 나는지(오르판 규칙)를 직접 겪어보게 됨.

📌 개념

문제 — Value를 Option<T>로 바로 변환할 수 없음

let fee: Decimal = fee.try_into().unwrap();          // 됨
let fee: Option<Decimal> = fee.try_into().unwrap();   // 안 됨 — trait bound 에러

이슈에 적힌 워크어라운드:

let fee: Option<Decimal> = match !fee.is_null() {
    true => Some(Decimal::try_from(fee).unwrap()),
    false => None,
};

Value::Null이면 None, 아니면 Some(T)로 변환하는 게 자연스러운데 이걸 라이브러리가 대신 안 해줌.

원인 — 기존 매크로가 T만 만들고 Option<T>는 안 만듦

core/src/data/value/convert.rs에 이미 반복 패턴을 없애는 매크로가 있음:

// core/src/data/value/convert.rs:16-27
macro_rules! try_from_owned_value {
    ($($target:ty), *) => {$(
        impl TryFrom<Value> for $target {
            type Error = ValueError;
            fn try_from(v: Value) -> Result<Self> {
                Self::try_from(&v)   // TryFrom<&Value>로 위임
            }
        }
    )*}
}
 
try_from_owned_value!(
    bool, i8, i16, i32, i64, i128, f32, f64, u8, u16, u32, u64, u128, usize, Decimal
);

bool/i8/…/Decimal 각각에 대해 TryFrom<Value> for $target만 생성하고, TryFrom<Value> for Option<$target>은 아예 없음.

왜 “그냥 제네릭 blanket impl 하나”로는 안 되는가

가장 먼저 떠오르는 방법:

// 이렇게 하고 싶지만 컴파일 안 됨 (E0210)
impl<T> TryFrom<Value> for Option<T>
where
    T: TryFrom<Value, Error = ValueError>,
{
    type Error = ValueError;
    fn try_from(v: Value) -> Result<Self> {
        match v {
            Value::Null => Ok(None),
            v => T::try_from(v).map(Some),
        }
    }
}

오르판 규칙(orphan rule)에 걸림. TryFrom도 Option도 이 크레이트가 정의한 게 아니라 둘 다 외부(std) 것이고, Self 타입인 Option<T> 안의 T가 제네릭 파라미터로 “가려지지 않은 채(uncovered)” 남아있음. 커버 규칙상 “로컬 타입(Value)이 처음 등장하기 전에 나오는 타입 파라미터는 전부 로컬 타입 안에 싸여 있어야” 하는데, Self = Option<T>가 먼저 나오고 T가 벌거벗은 채 있어서 위반. (Value가 TryFrom<Value>의 제네릭 인자로 로컬 타입이라 조건 일부는 만족하지만, Self 쪽 T가 안 가려진 게 문제.)

→ 실제로 이 코드를 타이핑해서 cargo check 에러 메시지(E0210: type parameter T must be covered by another type when it appears before the first local type)를 직접 보는 것 자체가 이번 작업의 학습 포인트.

고쳐야 하는 pseudocode — 매크로로 타입별 구체 impl 생성

제네릭 blanket impl 대신, 기존 매크로를 확장해서 매크로 전개 시점에 각 타입마다 구체적인(제네릭 아닌) impl을 여러 개 찍어냄. 이러면 매크로가 펼쳐진 뒤의 코드에는 impl<T> ...가 아니라 impl TryFrom<Value> for Option<Decimal>처럼 타입 파라미터가 없는 impl만 남아서 오르판 규칙에 안 걸림:

macro_rules! try_from_owned_value {
    ($($target:ty), *) => {$(
        impl TryFrom<Value> for $target {
            type Error = ValueError;
            fn try_from(v: Value) -> Result<Self> {
                Self::try_from(&v)
            }
        }
 
        // 여기 추가
        impl TryFrom<Value> for Option<$target> {
            type Error = ValueError;
            fn try_from(v: Value) -> Result<Self> {
                match v {
                    Value::Null => Ok(None),
                    v => <$target>::try_from(v).map(Some),
                }
            }
        }
    )*}
}
  • 매크로 호출부(try_from_owned_value!(bool, i8, ..., Decimal))는 그대로 두면, 이미 나열된 15개 타입 전부에 대해 Option<T> impl이 한 번에 생김
  • TryFrom<&Value>(참조 버전)까지 Option으로 확장할지는 이슈 범위 확인 필요 — 워크어라운드 코드가 소유(owned) Value만 다루고 있어서 우선 owned만 해도 이슈는 해결됨

확인할 것 (일요일 작업 전에)

  • 이슈가 2024년(오래됨) 것이라, 지금 main에서도 정말 안 되는지부터 재현 확인 — REPL이나 작은 테스트로 let x: Option<Decimal> = Value::Null.try_into() 시도
  • Value::Null → None 규칙이 이 코드베이스의 다른 Option 변환 관례와 일치하는지 (core/src/data/value.rs에 비슷한 패턴 있는지 검색)

테스트 방법

  1. convert.rs 안에 이미 있는 TryFrom 테스트들 근처(비슷한 이름의 mod tests가 있는지 확인, 없으면 새로 추가)에 케이스 작성:
    assert_eq!(Option::<i64>::try_from(Value::I64(7)), Ok(Some(7)));
    assert_eq!(Option::<i64>::try_from(Value::Null), Ok(None));
    assert!(Option::<i64>::try_from(Value::Str("x".into())).is_err());
  2. cargo test -p gluesql-core --lib data::value::convert
  3. cargo clippy -p gluesql-core --all-targets -- -D warnings, cargo fmt --all

배울 Rust 개념

  • 매크로(macro_rules!): $target:ty 반복 패턴 안에 impl 블록을 두 개로 늘리는 것만으로 15개 타입 전부에 코드가 찍혀 나오는 걸 체감
  • 트레잇과 제네릭 / 오르판 규칙: “제네릭이면 다 될 것 같다”는 직관이 깨지는 지점 — coherence(일관성) 규칙이 왜 필요한지, 매크로로 우회하는 게 왜 합법적인지
  • 에러 처리: Result<Self, ValueError> 안에서 match + .map(Some)으로 Option을 감싸는 패턴

🔗 참고

관련 문서