📖

RustでSQLxで扱えるPostgreSQLの型について

2024/12/01に公開

目的

ユニークビジョン株式会社 Advent Calendar 2024の12/1の記事です。

RustのSQLxが便利です。コネクションプールが独自に用意されていたり、SQLの結果とstructの変換が簡単だったりします。
PostgreSQLの型がどれだけ使えるか検証してみました。

基本型

基本的な型のマッピングは以下の表になります。

PostgreSQL Rust
BOOL bool
BOOL[] Vec<bool>
NULL::bool Option<bool>
CHAR i8
SMALLINT i16
INT i32
BIGINT i64
REAL f32
DOUBLE f64
TEXT String
BYTEA Vec<u8>
INET std::net::IpAddr
TIMESTAMP NaiveDateTime
TIMESTAMPTZ DateTime<Utc>
DATE NativeDate
TIME NativeTime
JSONB serde_json::Value
UUID uuid::Uuic
VARBIT bit_vec::BitVec
NUMERIC rust_decimal::Decimal
OID sqlx::postgres::types::Oid
MACADDR sqlx::types::mac_address::MacAddress
HSTORE sqlx::postgres::types::PgHstore

値の相互変換したコードは以下のようになります。

use bit_vec::BitVec;
use chrono::prelude::*;
use rust_decimal::Decimal;
use serde_json::json;
use sqlx::{
    postgres::types::{Oid, PgHstore},
    prelude::*,
    types::mac_address::MacAddress,
    Pool, Postgres,
};
use std::{
    net::{IpAddr, Ipv4Addr},
    str::FromStr,
};
use uuid::Uuid;

const SQL: &str = r#"
SELECT
    $1::BOOL AS bool_val
    ,$2::BOOL[] AS bool_array_val
    ,$3::BOOL AS bool_option_some_val
    ,$4::BOOL AS bool_option_none_val
    ,$5::"char" AS char_val
    ,$6::SMALLINT AS smallint_val
    ,$7::INT AS int_val
    ,$8::OID AS oid_val
    ,$9::BIGINT AS bigint_val
    ,$10::REAL AS real_val
    ,$11::DOUBLE PRECISION AS double_val
    ,$12::TEXT AS text_val
    ,$13::BYTEA AS bytes_val
    ,$14::INET AS inet_val
    ,$15::TIMESTAMP AS timestamp_val
    ,$16::TIMESTAMPTZ AS timestamptz_val
    ,$17::DATE AS date_val
    ,$18::TIME AS time_val
    ,$19::MACADDR AS macaddr_val
    ,$20::JSONB AS jsonb_val
    ,$21::UUID AS uuid_val
    ,$22::VARBIT AS varbit_val
    ,$23::NUMERIC AS decimal_val
    ,$24::HSTORE AS hstore_val
"#;

#[derive(FromRow, Debug, PartialEq)]
struct Data {
    bool_val: bool,
    bool_array_val: Vec<bool>,
    bool_option_some_val: Option<bool>,
    bool_option_none_val: Option<bool>,
    char_val: i8,
    smallint_val: i16,
    int_val: i32,
    oid_val: Oid,
    bigint_val: i64,
    real_val: f32,
    double_val: f64,
    text_val: String,
    bytes_val: Vec<u8>,
    inet_val: IpAddr,
    timestamp_val: NaiveDateTime,
    timestamptz_val: DateTime<Utc>,
    date_val: NaiveDate,
    time_val: NaiveTime,
    macaddr_val: MacAddress,
    jsonb_val: serde_json::Value,
    uuid_val: Uuid,
    varbit_val: BitVec,
    decimal_val: Decimal,
    hstore_val: PgHstore,
}

pub async fn execute(pool: &Pool<Postgres>) -> Result<(), sqlx::Error> {
    let data = Data {
        bool_val: true,
        bool_array_val: vec![true, false],
        bool_option_some_val: Some(true),
        bool_option_none_val: None,
        char_val: 1,
        smallint_val: 2,
        int_val: 3,
        oid_val: Oid(4),
        bigint_val: 5,
        real_val: 6.1,
        double_val: 7.1,
        text_val: "予定表~①💖ハンカクだ".to_string(),
        bytes_val: vec![240, 159, 146, 150],
        inet_val: IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
        timestamp_val: NaiveDateTime::from_str("2001-02-03T04:05:06").unwrap(),
        timestamptz_val: Utc.with_ymd_and_hms(2001, 2, 3, 4, 5, 6).unwrap(),
        date_val: NaiveDate::from_ymd_opt(2002, 3, 4).unwrap(),
        time_val: NaiveTime::from_hms_milli_opt(8, 59, 59, 100).unwrap(),
        macaddr_val: MacAddress::new([0x12, 0x34, 0x56, 0xAB, 0xCD, 0xEF]),
        jsonb_val: json!({
            "name" : "予定表~①💖ハンカクだ",
            "age" : 99
        }),
        uuid_val: Uuid::new_v4(),
        varbit_val: {
            let mut varbit_val = BitVec::from_elem(10, false);
            varbit_val.set(2, true);
            varbit_val
        },
        decimal_val: Decimal::from_str("1.1").unwrap(),
        hstore_val: {
            let mut val = PgHstore::default();
            val.insert("key".to_owned(), Some("value".to_owned()));
            val
        },
    };

    let res: Data = sqlx::query_as(SQL)
        .bind(&data.bool_val)
        .bind(&data.bool_array_val)
        .bind(&data.bool_option_some_val)
        .bind(&data.bool_option_none_val)
        .bind(&data.char_val)
        .bind(&data.smallint_val)
        .bind(&data.int_val)
        .bind(&data.oid_val)
        .bind(&data.bigint_val)
        .bind(&data.real_val)
        .bind(&data.double_val)
        .bind(&data.text_val)
        .bind(&data.bytes_val)
        .bind(&data.inet_val)
        .bind(&data.timestamp_val)
        .bind(&data.timestamptz_val)
        .bind(&data.date_val)
        .bind(&data.time_val)
        .bind(&data.macaddr_val)
        .bind(&data.jsonb_val)
        .bind(&data.uuid_val)
        .bind(&data.varbit_val)
        .bind(&data.decimal_val)
        .bind(&data.hstore_val)
        .fetch_one(pool)
        .await?;
    assert_eq!(data, res);
    println!("{:?}", data);

    Ok(())
}

crate postgresでサポートされているPostGIS用の型は未サポートでした。これらを扱う場合はpostgresを使う必要があります。

  • POINT
  • BOX
  • PATH

複合型

PostgreSQLではTypeやDomainやEnumが定義できます。これらをRustにマッピングしてみます。

PostgreSQLの定義は以下の通りです。

DROP TYPE IF EXISTS type_enum_os CASCADE;
CREATE TYPE type_enum_os AS ENUM (
  'linux',
  'mac_os',
  'windows'
);

DROP DOMAIN IF EXISTS domain_integer CASCADE;
CREATE DOMAIN domain_integer AS BIGINT CHECK(VALUE >= 0);

DROP TYPE IF EXISTS type_sample_get_select_composite CASCADE;
CREATE TYPE type_sample_get_select_composite AS (
  os type_enum_os,
  cpu_count domain_integer,
  memory_size BIGINT
);

CREATE OR REPLACE FUNCTION sample_get_select_composite(
  p_value type_sample_get_select_composite DEFAULT NULL
) RETURNS SETOF type_sample_get_select_composite AS $FUNCTION$
DECLARE
BEGIN
  RETURN NEXT p_value;
  RETURN NEXT p_value;
END;
$FUNCTION$ LANGUAGE plpgsql;

Rustのコードはこちらです。

type_nameを使うことでPostgreSQLに定義されている型と一致させることができます。

ちなみにstructになぜかrenameが使えなかったので少し不便ですがRustとSQLの変数名はsnake_caseなので基本的にはなんとかなると思います。

use sqlx::{prelude::*, Pool, Postgres};
const SQL: &str = r#"
SELECT
    t1.*
FROM
    sample_get_select_composite(
        p_value := $1
    ) AS t1 
"#;

#[derive(Debug, sqlx::Type)]
#[sqlx(type_name = "type_enum_os", rename_all = "snake_case")]
enum TypeEnumOs {
    Linux,
    MacOs,
    Windows,
}

#[derive(sqlx::Type, Debug)]
#[sqlx(transparent)]
struct DomainInteger(i64);

#[derive(sqlx::Type, Debug)]
#[sqlx(transparent)]
struct MemorySizeUnit(i64);

#[derive(FromRow, Debug, sqlx::Type)]
#[sqlx(type_name = "type_sample_get_select_composite")]
struct TypePgGetSelectComposite {
    os: TypeEnumOs,
    cpu_count: DomainInteger,
    memory_size: MemorySizeUnit,
}

pub async fn execute(pool: &Pool<Postgres>) -> Result<(), sqlx::Error> {
    let data = TypePgGetSelectComposite {
        os: TypeEnumOs::MacOs,
        cpu_count: DomainInteger(2),
        memory_size: MemorySizeUnit(1024),
    };

    let row: Vec<TypePgGetSelectComposite> =
        sqlx::query_as(SQL).bind(&data).fetch_all(pool).await?;

    println!("{:?}", row);
    Ok(())
}

おまけ

ストアード・プロシージャー内でログの出力で使われるRAISE文はenv_loggerを用意すればSQLxでも確認できます。

RAISE NOTICE 'Hello World!';
env_logger::init();

Discussion