> ## Documentation Index
> Fetch the complete documentation index at: https://villagesql.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Rust에서의 사용자 정의 타입

> VillageSQL Rust SDK에서 새로운 컬럼 타입을 정의합니다 — custom_type! 매크로를 사용하여 이진 레이아웃, 인코딩, 디코딩, 비교, 해시 및 산술 함수를 제공합니다.

<Warning>
  Rust SDK는 알파 단계입니다 — 릴리스 간에 호환성이 깨지는 API 변경이
  발생할 수 있습니다. 함수 전용 확장, 집계 함수, 가변 인자 함수, 그리고
  사용자 정의 타입(encode, decode, compare, hash)이 지원되며, `sys_var`,
  `status_var`, `thread_worker`, `keyring` 미리보기 기능도 지원됩니다.
  컬럼 저장 ABI는 현재 C++ 전용입니다 — 이 기능이 필요하다면
  [C++ SDK](/docs/ko/mysql-9.7/stable/create)를 사용하세요.
</Warning>

사용자 정의 타입을 통해 `RATIONAL`, `VECTOR`, 또는 `INET`과 같은 새로운 컬럼 타입을 정의할 수 있습니다. 이러한 타입은 `ORDER BY`, 인덱스, 및 집계 함수와 함께 작동합니다. Rust SDK는 `custom_type!` 매크로를 통해, 그리고 저장 크기가 컬럼 매개변수에 따라 달라지는 타입에 대해서는 `parameterized_type!`을 통해 이를 지원합니다([매개변수화된 타입](#parameterized-types) 참조).

이 페이지는 이미 [Rust에서 확장 빌드](/docs/ko/mysql-9.7/stable/rust-sdk)를 완료했음을 전제로 합니다. 설정(Cargo.toml, manifest.json, cargo-vsql)은 동일합니다.

## 사용자 정의 타입을 사용해야 하는 경우

다음과 같은 경우 사용자 정의 타입을 사용하세요:

* 표준 SQL 타입으로 표현할 수 없는 디스크 상의 이진 레이아웃이 필요할 때(압축된 부동소수점, 고정 폭 정수, 이진 식별자)
* 타입 자체가 사전식 문자열 정렬과 다른 순서 의미론을 가질 때
* 서버가 `ORDER BY`, `COUNT(DISTINCT)`, 및 집합 연산을 위해 값들을 올바르게 인덱싱하고 해시하도록 하려 할 때

SQL 호출 가능한 함수만 필요하고 데이터를 `STRING`, `INT`, 또는 `REAL` 컬럼에 무리 없이 담을 수 있을 경우, 사용자 정의 타입이 필요하지 않습니다.

## custom\_type! 매크로

모든 사용자 정의 타입은 4개의 콜백(인코딩, 디코딩, 비교, 해시)과 기본값이 필요합니다. 전체 매크로 서명은 다음과 같습니다:

```rust theme={null}
villagesql::custom_type!(
    type_name: "type_name_in_sql",
    persisted_length: N,
    max_decode_buffer_length: M,
    encode: your_encode_fn,
    decode: your_decode_fn,
    compare: your_compare_fn,
    hash: your_hash_fn,
    default: "a_valid_string_literal",
)
```

| 필드                         | 유형      | 설명                                                                                    |
| -------------------------- | ------- | ------------------------------------------------------------------------------------- |
| `type_name`                | 문자열 리터럴 | SQL 타입 이름. SQL에서는 대소문자 구분 없음.                                                         |
| `persisted_length`         | `usize` | 디스크 저장을 위한 고정 바이트 길이.                                                                 |
| `max_decode_buffer_length` | `usize` | 디코딩된 문자열 표현의 최대 바이트 길이.                                                               |
| `encode`                   | fn      | INSERT 시에 `&str`를 이진 바이트로 변환합니다.                                                      |
| `decode`                   | fn      | 이진 바이트를 표시용 `String`으로 변환합니다.                                                         |
| `compare`                  | fn      | `ORDER BY`, `MIN`, `MAX`에 대한 `Ordering`을 반환합니다.                                       |
| `hash`                     | fn      | `COUNT(DISTINCT)` 및 집합 연산을 위한 `usize` 해시를 반환합니다. 선택적이지만 인덱스가 있는 컬럼에는 권장됩니다.           |
| `default`                  | 문자열 리터럴 | 서버가 타입 초기화 시 인코딩할 수 있는 유효한 문자열. 정확히 `persisted_length` 바이트로 인코딩되어야 합니다. 선택적이지만 권장됩니다. |

`type_name`, `persisted_length`, `max_decode_buffer_length`, `encode`, `decode`, `compare`는 필수입니다. `hash`와 `default`는 선택적이지만 권장됩니다 — `hash`는 올바른 `COUNT(DISTINCT)` 및 집합 연산을 위해 필요하며, `default`는 타입 초기화 검증을 위해 필요합니다.

## 이진 값 수신 및 반환

사용자 정의 타입을 받거나 반환하는 함수는 원시 바이트를 다룹니다.

**입력** — `InValue::Custom(b)`는 저장된 이진 데이터를 `&[u8]`로 전달합니다:

```rust theme={null}
fn rational_numer_impl(args: &[InValue]) -> VdfReturn {
    match args.first() {
        Some(InValue::Custom(b)) => {
            let numer = read_i64(b, 0);
            VdfReturn::int(numer)
        }
        Some(InValue::Null) | None => VdfReturn::null(),
        _ => VdfReturn::error("rational_numer: expected a RATIONAL argument"),
    }
}
```

**출력** — `VdfReturn::Binary(bytes)`는 이진 바이트를 서버로 전송합니다:

```rust theme={null}
fn rational_add_impl(args: &[InValue]) -> VdfReturn {
    match (args.get(0), args.get(1)) {
        (Some(InValue::Custom(a)), Some(InValue::Custom(b))) => {
            let result = add_rationals(a, b);
            VdfReturn::Binary(result)
        }
        _ => VdfReturn::null(),
    }
}
```

`func!` 선언에서 사용자 정의 타입을 참조하려면 `villagesql::custom!("type_name")`을 사용합니다:

```rust theme={null}
villagesql::func!(
    rational_add_impl,
    "rational_add",
    [villagesql::custom!("rational"), villagesql::custom!("rational")] -> villagesql::custom!("rational"),
    deterministic: true
)
```

## 예시: 유리수 타입

SDK 리포지토리의 `examples/vsql_rational`은 `RATIONAL` 타입을 구현하는 작동하는 확장입니다. 이 타입은 유리수를 리틀 엔디안 바이트 순서로 16바이트의 `i64` 값 쌍(분자, 분모)으로 저장하고 산술 함수를 제공합니다.

다음은 핵심 인코딩, 디코딩, 비교, 해시 구현입니다:

```rust theme={null}
use villagesql::{InValue, VdfReturn};

// Binary layout: [numerator: i64 LE][denominator: i64 LE] — 16 bytes total.
// Always stored in reduced form (GCD = 1) with a positive denominator.
const BYTES: usize = 16;

fn to_bytes(num: i64, den: i64) -> Vec<u8> {
    let mut v = Vec::with_capacity(BYTES);
    v.extend_from_slice(&num.to_le_bytes());
    v.extend_from_slice(&den.to_le_bytes());
    v
}

fn from_bytes(b: &[u8]) -> (i64, i64) {
    let num = i64::from_le_bytes(b[..8].try_into().unwrap());
    let den = i64::from_le_bytes(b[8..16].try_into().unwrap());
    (num, den)
}

// encode: "3/4" -> 16 bytes
pub fn rational_encode(s: &str) -> Result<Vec<u8>, String> {
    let (num_s, den_s) = s
        .split_once('/')
        .ok_or_else(|| format!("rational: expected 'n/d', got {:?}", s))?;
    let num: i64 = num_s.trim().parse()
        .map_err(|e| format!("rational numerator: {}", e))?;
    let den: i64 = den_s.trim().parse()
        .map_err(|e| format!("rational denominator: {}", e))?;
    let (n, d) = normalize(num as i128, den as i128)
        .ok_or_else(|| "rational: zero or overflowing denominator".to_string())?;
    Ok(to_bytes(n, d))
}

// decode: 16 bytes -> "3/4"
pub fn rational_decode(b: &[u8]) -> Result<String, String> {
    if b.len() < BYTES {
        return Err(format!("rational: expected {} bytes, got {}", BYTES, b.len()));
    }
    let (n, d) = from_bytes(b);
    Ok(format!("{}/{}", n, d))
}

// compare: for ORDER BY, MIN, MAX
pub fn rational_compare(a: &[u8], b: &[u8]) -> std::cmp::Ordering {
    let (n1, d1) = from_bytes(a);
    let (n2, d2) = from_bytes(b);
    // cross-multiply (denominators are always positive)
    let lhs = (n1 as i128) * (d2 as i128);
    let rhs = (n2 as i128) * (d1 as i128);
    lhs.cmp(&rhs)
}

// hash: for COUNT(DISTINCT) and set operations
pub fn rational_hash(b: &[u8]) -> usize {
    // FNV-1a over the 16 bytes
    let mut h: usize = 0xcbf29ce484222325u64 as usize;
    for &byte in b {
        h ^= byte as usize;
        h = h.wrapping_mul(0x100000001b3u64 as usize);
    }
    h
}
```

`custom_type!` 등록 및 산술 VDF(`rational_add`, `rational_sub` 등)는 `examples/vsql_rational/src/lib.rs`의 전체 소스에 있습니다.

확장이 설치된 후:

```sql theme={null}
INSTALL EXTENSION vsql_rational;

CREATE TABLE fractions (
    id   INT PRIMARY KEY,
    val  RATIONAL
);

INSERT INTO fractions VALUES (1, '1/2'), (2, '3/4'), (3, '1/4');

-- ORDER BY uses rational_compare
SELECT val FROM fractions ORDER BY val;
-- → 1/4, 1/2, 3/4

-- Arithmetic with rational_add
SELECT rational_add('1/3', '1/6');
-- → 1/2

-- Extract numerator and denominator
SELECT rational_numer(val), rational_denom(val) FROM fractions;

-- Convert to floating-point approximation
SELECT rational_to_real('1/3');
-- → 0.3333333333333333
```

`rational_to_real(r RATIONAL) -> REAL`은 분자를 분모로 나누어 `RATIONAL` 값을 64비트 부동소수점 근사치로 변환합니다. 표시나 비교를 위해 근사 소수를 필요로 하지만 열에 손실형 표현을 저장하고 싶지 않을 때 유용합니다.

<h2 id="parameterized-types">
  매개변수화된 타입
</h2>

매개변수화된 타입은 저장 크기를 알기 위해 `CREATE TABLE` 시점에 읽는
값이 필요합니다 — `VECTOR(3)`의 `3` 같은 값입니다. `custom_type!`은
이를 표현할 수 없습니다: 그 `persisted_length`는 모든 컬럼에 대해
하나의 고정 상수입니다. `parameterized_type!`은 매개변수화된 대응물로,
저장 길이가 고정되는 대신 `int_to_params`와 `resolve_params`를 통해
선언된 매개변수로부터 컬럼마다 계산됩니다.

```rust theme={null}
villagesql::parameterized_type!(
    type_name: "type_name_in_sql",
    max_persisted_length: N,
    max_decode_buffer_length: M,
    encode: your_encode_fn,
    decode: your_decode_fn,
    compare: your_compare_fn,
    int_to_params: your_int_to_params_fn,
    resolve_params: your_resolve_params_fn,
    params_type: YourParamsType,
    params_parse: your_params_parse_fn,
    params_to_strings: your_params_to_strings_fn,
    hash: your_hash_fn,
    default: "a_valid_string_literal",
)
```

| 필드                         | 유형      | 설명                                                                            |
| -------------------------- | ------- | ----------------------------------------------------------------------------- |
| `type_name`                | 문자열 리터럴 | SQL 타입 이름. SQL에서는 대소문자 구분 없음.                                                 |
| `max_persisted_length`     | `i64`   | 모든 유효한 매개변수화에 걸친 디스크 상 바이트 길이의 상한.                                            |
| `max_decode_buffer_length` | `usize` | 디코딩된 문자열 표현의 최대 바이트 길이.                                                       |
| `encode`                   | fn      | INSERT 시에 `&str`와 `&mut MaybeParams<P>`를 이진 바이트로 변환합니다.                       |
| `decode`                   | fn      | 이진 바이트와 `&P`를 표시용 `String`으로 다시 변환합니다.                                        |
| `compare`                  | fn      | `&P`가 주어졌을 때 `ORDER BY`, `MIN`, `MAX`에 대한 `Ordering`을 반환합니다.                  |
| `int_to_params`            | fn      | `TYPE(N)` 정수 구문을 정규 매개변수 문자열로 변환합니다.                                          |
| `resolve_params`           | fn      | 파싱된 `Params`를 검증하고 `Resolved`(저장 크기, 선택적으로 다시 작성된 매개변수)를 반환합니다.               |
| `params_type`              | 타입      | 파싱된 매개변수 구조체입니다. 예: `PadintParams`.                                           |
| `params_parse`             | fn      | `Params`를 `params_type`으로 파싱합니다. 고유한 매개변수 조합마다 최대 한 번 실행됩니다 — SDK가 결과를 캐시합니다. |
| `params_to_strings`        | fn      | `params_parse`의 역방향입니다: `params_type`을 정규 `(key, value)` 쌍으로 다시 씁니다.          |
| `hash`                     | fn      | `&P`가 주어졌을 때 `usize` 해시를 반환합니다. 선택적이지만 인덱스가 있는 컬럼에는 권장됩니다.                    |
| `default`                  | 문자열 리터럴 | 서버가 타입 초기화 시 인코딩할 수 있는 유효한 문자열. 선택적이지만 권장됩니다.                                 |

`type_name`, `max_persisted_length`, `max_decode_buffer_length`, `encode`, `decode`, `compare`, `int_to_params`, `resolve_params`, `params_type`, `params_parse`, `params_to_strings`는 필수입니다. `hash`와 `default`는 선택적입니다. `intrinsic_default_fn`(기본값이 매개변수에 따라 달라질 때 `&P`로부터 기본값을 계산하는 함수) 또한 선택적이며 `default`와 상호 배타적입니다.

다음은 `padint`입니다 — 고정된 8바이트에 저장되는 `i64`이며, `width` 매개변수가 0으로 채워지는 표시 폭을 제어합니다:

```rust theme={null}
pub struct PadintParams {
    pub width: u32,
}

pub fn padint_parse(params: Params) -> PadintParams {
    let width = params.get("width").and_then(|s| s.parse().ok()).unwrap_or(1);
    PadintParams { width }
}

pub fn padint_to_strings(p: &PadintParams) -> Vec<(String, String)> {
    vec![("width".to_string(), p.width.to_string())]
}

pub fn padint_int_to_params(n: i64) -> Result<String, String> {
    if n < 1 {
        return Err(format!("padint: width must be >= 1, got {n}"));
    }
    Ok(format!("width={n}"))
}

pub fn padint_resolve_params(params: Params) -> Result<Resolved, String> {
    let width: i64 = params
        .get("width")
        .ok_or_else(|| "padint: missing 'width' param".to_string())?
        .parse()
        .map_err(|e| format!("padint: bad width: {e}"))?;
    // Fixed 8-byte i64 storage; the decode buffer must fit the widest
    // rendering, which is the larger of an i64's digits and the padded width.
    Ok(Resolved::new(8, width.max(20)))
}

pub fn padint_encode(s: &str, params: &mut MaybeParams<PadintParams>) -> Result<Vec<u8>, String> {
    let n: i64 = s.trim().parse().map_err(|e| format!("padint: {e}"))?;
    if !params.is_known() {
        // Bare constant with no column to anchor it: infer width from digits.
        let digits = s.trim().trim_start_matches('-').len().max(1);
        params.set(PadintParams { width: u32::try_from(digits).unwrap_or(u32::MAX) });
    }
    Ok(n.to_le_bytes().to_vec())
}

pub fn padint_decode(b: &[u8], p: &PadintParams) -> Result<String, String> {
    let n = i64::from_le_bytes(b[..8].try_into().unwrap());
    Ok(format!("{n:0width$}", width = p.width as usize))
}

pub fn padint_compare(a: &[u8], b: &[u8], _p: &PadintParams) -> std::cmp::Ordering {
    i64::from_le_bytes(a[..8].try_into().unwrap()).cmp(&i64::from_le_bytes(b[..8].try_into().unwrap()))
}

villagesql::parameterized_type!(
    type_name: "padint",
    max_persisted_length: 8,
    max_decode_buffer_length: 32,
    encode: padint_encode,
    decode: padint_decode,
    compare: padint_compare,
    int_to_params: padint_int_to_params,
    resolve_params: padint_resolve_params,
    params_type: PadintParams,
    params_parse: padint_parse,
    params_to_strings: padint_to_strings,
    default: "0",
)
```

매개변수화된 사용자 정의 타입 인수를 받는 함수는 일반
`InValue::Custom(bytes)`가 아니라
`InValue::CustomWithParams { bytes, params }`를 봅니다 — `params`는
컬럼에 선언된 `key=value` 쌍에 대한 읽기 전용, 무복사 뷰인
\[`TypeParams`]입니다.

## extension! 블록과 타입

함수와 타입을 모두 등록할 때, `extension!` 블록은 두 개의 섹션을 가집니다:

```rust theme={null}
villagesql::extension! {
    funcs: [
        // VDFs declared with func!
    ],
    types: [
        // Custom types declared with custom_type!
    ]
}
```

함수만 포함하는 확장은 `types:`를 생략합니다. 타입만 포함하는 확장은 `funcs: []`를 유지하며 그 밖의 것은 생략하지 않습니다.

## 다음 단계

<CardGroup cols={2}>
  <Card title="Rust API 참조" icon="book" href="/docs/ko/mysql-9.7/stable/rust-api-reference">
    InValue, VdfReturn, 및 모든 매크로에 대한 완전한 참조.
  </Card>

  <Card title="Rust에서 확장 빌드" icon="wrench" href="/docs/ko/mysql-9.7/stable/rust-sdk">
    시작하기 — Cargo 설정, 첫 번째 함수, 패키징 및 테스트.
  </Card>

  <Card title="C++ 사용자 정의 타입" icon="shapes" href="/docs/ko/mysql-9.7/stable/custom-types">
    C++에서의 사용자 정의 타입 — `make_type<>`, 인코딩/디코딩/비교/해시, ALTER TABLE 규칙.
  </Card>

  <Card title="확장 아키텍처" icon="sitemap" href="/docs/ko/mysql-9.7/stable/architecture">
    사용자 정의 타입이 해석, 캐시, 저장되는 방식.
  </Card>
</CardGroup>
