본문으로 건너뛰기

데이터베이스

이 페이지는 영어에서 번역되었습니다. 영어 페이지 읽기

zeyn::sql은 entity를 SQL 데이터베이스에 보관합니다. 그리드가 보내는 DataQuery(필터, 정렬, 페이지, 합계, 그룹)로 목록을 만들고 행 버전 검사와 함께 저장합니다. PostgreSQL은 postgres Cargo 기능, Oracle은 oracle 기능 뒤에 있으므로 데이터베이스가 없는 앱은 이 중 아무것도 컴파일하지 않습니다.

zeyn = { version = "0.0.7", features = ["postgres"] }   # or ["oracle"], or both

설정

Db 하나를 서비스로 등록하고 action에서 받습니다.

let db = Db::from_config(&config).await?;
App::new("Stock").config(config).service(db)

#[action(permission = "stock.product.read")]
pub async fn search_products(q: DataQuery, db: Db) -> Result<Page<Product>> {
    db.find(&q).await
}

#[action(permission = "stock.product.write")]
pub async fn save_product(p: Valid<Product>, db: Db) -> Result<Product> {
    if p.id.0 == 0 { db.insert(&*p).await } else { db.update(&*p).await }
}

Db::from_config는 database.url을 시크릿으로, 풀 제한을 database.*에서 읽습니다.

키기본값의미
database.url필수postgres://user:password@host:5432/db 또는 oracle://user:password@host:1521/service
database.max_connections10풀 크기. 요청은 빈 연결을 기다립니다
database.min_connections0유휴 상태에서 열어 두는 연결 수
database.acquire_timeout_ms5000연결을 기다리는 최대 시간
database.idle_timeout_s600최소치를 넘는 유휴 연결은 이 시간 후 닫힙니다
database.statement_timeout_ms30000구문이 실행될 수 있는 최대 시간. 0은 서버 기본값을 유지합니다
database.count_cap1000000페이지에 대해 세는 최대 합계

Db::connect(url, PoolLimits { .. })는 Config 없이 같은 일을 합니다. URL 스킴이 드라이버를 고릅니다. Oracle 비밀번호의 @나 : 같은 문자는 퍼센트 인코딩(%40)으로 씁니다.

PostgreSQL 연결은 지연되어 열리므로 데이터베이스가 중단된 상태에서도 앱이 시작되며 첫 쿼리가 문제를 보고합니다. Oracle은 URL이 작성할 SQL을 지정하지 않으면(?compat=11 또는 ?compat=12) 연결할 때 서버에 버전을 묻습니다. compat을 설정하면 데이터베이스 없이도 시작합니다.

inventory 예제는 제품을 메모리에, 또는 ZEYN_DATABASE_URL이 지정한 데이터베이스에 보관합니다(--features postgres 또는 --features oracle로 빌드).

docker run -d --name zeyn-pg -e POSTGRES_PASSWORD=zeyn -p 55432:5432 postgres:17-alpine
ZEYN_DATABASE_URL=postgres://postgres:zeyn@127.0.0.1:55432/postgres cargo run -p zeyn-example-inventory --features postgres

Oracle Instant Client

Oracle 드라이버는 ODPI-C 위의 oracle 크레이트입니다. Oracle 소프트웨어 없이 컴파일되지만, 실행할 때 Oracle Client 라이브러리(oci.dll, libclntsh.so)를 로드합니다. Oracle Instant Client는 무료로 사용할 수 있습니다.

  1. Oracle의 Instant Client 페이지에서 플랫폼에 맞는 Instant Client Basic Light를 다운로드합니다. Windows x64에서는 zip instantclient-basiclite-windows.x64-19.*.zip을 받으십시오. 버전 19는 Oracle Database 11.2에서 23ai까지 연결되며, 더 새로운 클라이언트는 더 이상 11g에 연결되지 않습니다.
  2. 자체 폴더에 압축을 풉니다(예: C:\oracle\instantclient_19).
  3. 그 폴더를 PATH의 맨 앞에 두거나 ZEYN_ORACLE_CLIENT_DIR을 그 폴더로 설정합니다. 이 변수는 PATH보다 우선하므로 다른 Oracle 홈이 설치되어 있을 때 도움이 됩니다. Linux에서는 RPM을 설치하거나 LD_LIBRARY_PATH를 설정하십시오. Basic Light에는 libaio도 필요합니다.

라이브러리가 없으면 첫 연결은 DPI-1047: Cannot locate a 64-bit Oracle Client library로 실패합니다.

방언

SQL 빌더는 공유됩니다. Dialect는 다른 부분만 제공하며, Postgres와 Oracle { compat }이 이를 구현합니다.

PostgreSQLOracle 12c 이상 (V12)Oracle 11g (V11)
매개변수$1:1:1
식별자"code", 최대 63자"CODE", 최대 30자"CODE", 최대 30자
페이지LIMIT $n OFFSET $mOFFSET :m ROWS FETCH NEXT :n ROWS ONLY정렬된 쿼리 위의 ROWNUM 윈도우
새 키identity, RETURNINGidentity, RETURNING id INTO :n<table>_seq.NEXTVAL
쓰기 후 다시 읽기행을 RETURNING키로 읽기키로 읽기
예/아니요BOOLEANNUMBER(1)NUMBER(1)
빈 텍스트값NULLNULL

compat은 EF Core의 UseOracleSQLCompatibility("11")의 아이디어를 따릅니다. URL이 지정하지 않으면 서버 버전이 이를 고릅니다. 12.1이 11g와 같은 제한을 가지므로 식별자는 모든 Oracle 버전에서 30자 이내로 유지되며, 더 긴 entity나 필드 이름은 이를 지명하는 오류가 됩니다.

Oracle은 빈 문자열을 NULL로 저장합니다. Zeyn은 필수 String 필드의 NULL을 ""로 읽고, 텍스트 열을 널 허용으로 쓰며, 텍스트를 널 허용 열로 정렬하고 페이징하므로 커서가 정확하게 유지됩니다. Some("")을 담은 Option<String>은 None으로 다시 읽힙니다.

Oracle의 드라이버는 블로킹입니다. 모든 호출은 제한된 풀에서 가져온 연결과 함께 Tokio의 블로킹 스레드에서 실행되므로 비동기 런타임을 멈추게 하지 않습니다. 최대 max_connections개의 호출이 동시에 실행되며 나머지는 acquire_timeout_ms까지 기다립니다.

테이블

열은 필드의 이름을 따릅니다. 타입은 다음과 같습니다.

필드PostgreSQLOracle
#[key] entity idBIGINT GENERATED BY DEFAULT AS IDENTITYNUMBER(19) identity(12c+) 또는 시퀀스 사용(11g)
StringVARCHAR(max_len) 또는 TEXTVARCHAR2(max_len CHAR) 또는 VARCHAR2(4000), 널 허용
i32, i64BIGINT (INTEGER 열도 동작)NUMBER(19)
boolBOOLEANNUMBER(1)
Money최소 단위의 BIGINT 금액과 <field>_currency VARCHAR(3)NUMBER(19)와 <field>_currency
Decimal필드 규칙의 NUMERIC(precision, scale), 규칙이 없으면 NUMERIC(18, 2)NUMBER(precision, scale)
DateDATEDATE
TimeTIMEHH:MM:SS를 담는 VARCHAR2(8)
DateTimeTIMESTAMPTZ(3)UTC를 담는 TIMESTAMP(3)
LocalDateTimeTIMESTAMPTIMESTAMP(0)
#[choice] enumvariant 이름을 담는 VARCHARVARCHAR2
사용자 지정 필드 타입JSON을 담는 TEXTJSON을 담는 CLOB
Option<T>같은 열, 널 허용같은 열, 널 허용

통화는 TRY, USD, EUR로 저장됩니다. 날짜와 시간은 ISO 텍스트로 전달되고 SQL이 고정된 형식(TO_DATE(:1, 'YYYY-MM-DD'), to_char(...))으로 변환하므로, NLS_DATE_FORMAT이나 PostgreSQL 시간대 같은 세션 설정은 결과를 바꾸지 않습니다. Decimal은 소수 구분 기호가 없는 정확한 텍스트(12345E-2, CAST(... AS NUMERIC)와 TO_NUMBER(...)가 읽음)로 바인드되고 CAST(... AS TEXT) 또는 TO_CHAR(..., 'TM9', 'NLS_NUMERIC_CHARACTERS=''.,''')로 다시 읽히므로 NLS_NUMERIC_CHARACTERS도 이를 바꾸지 않습니다.

create_table::<E>(dialect)(또는 db.create_table::<E>())는 스캐폴딩과 테스트를 위한 CREATE TABLE을 작성하며, 모든 #[rule(unique)] 필드에 대해 <table>_<field>_key라는 이름의 제약 조건을, 11g에서는 키 시퀀스를 포함합니다. 운영 스키마는 마이그레이션에 속합니다. PostgreSQL에서는 다음과 같이 합니다.

db.migrate(&sqlx::migrate!("./migrations")).await?;
db.migrate_dir("./migrations").await?;

첫 번째는 마이그레이션을 컴파일할 때 내장하고, 두 번째는 실행할 때 폴더를 읽습니다. sqlx::migrate!에는 앱 자체의 Cargo.toml에 macros와 migrate 기능이 있는 sqlx가 필요하며, migrate_dir에는 더 필요한 것이 없습니다. Oracle 스키마는 팀 자체의 도구로 마이그레이션하며, create_table이 시작 스크립트를 제공합니다.

읽기

db.find::<E>(&query)는 하나의 연결에서 최대 세 개의 구문을 실행합니다. 페이지, 개수, 합계입니다. 필터는 DataQuery::apply와 같은 코드로 읽으므로, 목록은 메모리 데이터와 SQL에서 똑같이 동작합니다.

필터SQL
contains, starts_with텍스트의 %와 _가 이스케이프된 LOWER(col) LIKE $1 ESCAPE '\'
equalsLOWER(col) = $1
숫자와 금액의 from, tocol >= $1, col <= $2
날짜와 시간의 from, tocol >= CAST($1 AS date). 순간에 대한 날짜 경계는 사용자의 시간대에서 하루 전체를 포함합니다
any_ofcol IN ($1, $2, ...)
iscol = $1
blankcol IS NULL, col IS NOT NULL

금액과 예/아니요 열의 contains는 SQL에서 무시됩니다. 텍스트는 데이터베이스 콜레이션(Oracle은 NLS_SORT)으로 정렬됩니다.

모든 정렬은 키로 끝나므로 순서가 전체 순서입니다. NULL은 메모리에서처럼 오름차순에서 먼저, 내림차순에서 마지막에 오며, NULLS 절은 널 허용 열에만 작성되므로 일반 인덱스가 다른 정렬을 지원합니다.

get(key)는 행 하나를 읽고 없으면 Error::NotFound로 실패합니다. 모든 테이블이나 뷰를 entity를 통해 읽을 수 있으며, 예를 들어 Oracle의 user_sequences가 있습니다.

커서

번호가 있는 페이지는 오프셋을 사용합니다. 무한 스크롤과 가상 스크롤은 keyset 커서를 사용하며, 목록 깊은 곳에서도 느려지지 않고 다른 행이 삽입되어도 행을 건너뛰거나 반복하지 않습니다.

WHERE "code" <= $1 AND (("code" < $2) OR ("code" = $3 AND "id" > $4))
ORDER BY "code" DESC, "id" ASC LIMIT $5

페이지는 더 이어지는지 알기 위해 요청한 것보다 한 행을 더 가져옵니다. 그러면 Page::next가 커서를 담습니다. 정렬 필드, 방향, 각 정렬 키의 마지막 행 값(키가 마지막)을 담은 base64url JSON입니다. 커서는 클라이언트에게는 불투명하지만 비밀은 아니며, 서버는 이를 절대 신뢰하지 않습니다. 크기 제한과 함께 디코딩되고, 순서가 쿼리와 다르면 거부되며, 모든 값은 바인드되기 전에 열 타입에 대해 검사됩니다. 잘못된 커서는 쿼리가 아니라 검증 오류입니다. 커서는 어디서 이어갈지만 알려 주며, 필터와 권한은 다시 적용됩니다.

그룹화된 페이지는 커서에 오프셋을 사용합니다.

합계와 개수

with_total은 최대 database.count_cap개의 행을 세므로(상한이 있는 쿼리에 대한 COUNT(*)) 거대한 테이블의 합계도 저렴하게 유지됩니다. 개수가 상한에 도달하면 Page::total은 그 상한입니다.

열 합계는 필터와 일치하는 모든 행에 대한 하나의 집계 구문입니다. 메모리의 규칙을 따릅니다. 빈 합계는 0, 빈 평균은 비어 있음이고, 금액 평균은 0에서 멀어지는 방향으로 반올림하며, 둘 이상의 통화가 있는 금액은 합계가 없습니다.

DataQuery::group은 그룹별 개수와 요청된 합계가 있는 GROUP BY가 됩니다.

쓰기

  • insert(&row)는 새 키(0)를 데이터베이스에 맡기고, 행 버전을 1로 설정하며, 저장된 행을 반환합니다.
  • update(&row)는 UPDATE ... SET version = version + 1 WHERE id = $1 AND version = $2를 실행합니다. 행이 0개이면 다른 누군가가 먼저 저장했거나(또는 행을 삭제했습니다) Error::Conflict입니다.
  • delete::<E>(key)는 아무것도 삭제되지 않으면 Error::NotFound로 실패합니다.
  • #[readonly] 필드는 모델에서 절대 쓰이지 않으므로 위조된 폼이 이를 바꿀 수 없습니다. 서버 코드는 자체 SQL로 이를 변경합니다. db.execute("UPDATE products SET stock = stock - $1 WHERE id = $2", args). execute는 문자열 리터럴을 받으므로 값은 매개변수로만 도착할 수 있습니다.

고유 제약 위반(PostgreSQL 23505, Oracle ORA-00001)은 필드에 대한 검증 오류가 됩니다. 제약 조건 이름에 그 이름이 있는 #[rule(unique)] 필드, 또는 유일한 unique 필드입니다. 폼은 이를 다른 규칙처럼 입력 아래에 표시합니다. 제약 조건 이름은 <table>_<column>_key로 지으십시오.

트랜잭션

let mut tx = db.begin().await?;
let order = tx.insert(&order).await?;
tx.execute("UPDATE products SET stock = stock - $1 WHERE id = $2", vec![qty.into(), id.0.into()]).await?;
tx.commit().await?;

Tx는 하나의 연결에서 Db와 같은 호출을 가집니다. commit 없이 해제하면 롤백됩니다. Db 자체에 대한 호출은 하나씩 커밋됩니다.

스트림

db.stream::<E>(&query)는 내보내기를 위해 일치하는 모든 행을 페이징 없이 순서대로 반환합니다. 작업이 행을 STREAM_BUFFER(64)행 채널로 읽고 독자가 뒤처지면 기다리므로, 결과 크기와 상관없이 메모리가 일정하게 유지됩니다. 스트림을 해제하면 쿼리가 멈추고 연결이 해제됩니다. RowStream은 futures_core::Stream을 구현합니다.

보안: SQL 인젝션

  • 식별자(테이블과 열 이름)는 entity에서만 옵니다. E::TABLE과 그 FieldMeta 목록입니다. 클라이언트가 필터와 정렬에 보낸 이름은 그 목록에서 조회되며 없으면 버려집니다. 모든 식별자는 검사되고(문자, 숫자, _, 최대 방언의 길이) 따옴표로 묶입니다.
  • 값은 항상 바인드 매개변수입니다. LIKE 패턴, 커서 값, limit, offset도 포함합니다. 어떤 값도 SQL 텍스트에 기록되지 않습니다.
  • 직접 작성한 SQL은 execute(&'static str, args)를 거칩니다. 구문은 리터럴이어야 합니다.
  • 제한은 클라이언트가 무엇을 요청하든 서버에서 제한됩니다(MAX_PAGE_SIZE, MAX_FILTERS, MAX_SORT, 필터 텍스트 길이, any_of 크기).
  • 드라이버 오류는 Error::Internal로 기록되며 사용자에게 표시되지 않습니다. 연결 URL은 Secret으로 읽히며 기록되지 않습니다.

성능

  • 구문은 연결별로 준비되고 캐시됩니다(sqlx의 구문 캐시, Oracle의 64개 구문 캐시).
  • 행은 몇 가지 일반 셀 타입으로 읽히고 serde 디시리얼라이저에 의해 모델로 옮겨집니다. 중간 맵도 텍스트 복사도 없습니다.
  • 풀은 제한되어 있으며 각 find는 두세 개의 구문에 연결 하나를 사용합니다.
  • Oracle은 왕복당 128행을 가져옵니다. 페이지(최대 501행)는 블로킹 스레드에서 읽혀 한 번에 전달되며, 스트림은 행 단위로 진행합니다.
  • 스트림은 최대 STREAM_BUFFER개의 행을 보유합니다.

테스트

SQL 빌더에는 데이터베이스가 필요 없는 두 방언의 단위 테스트가 있습니다. 통합 테스트는 같은 행에 대해 Db::find와 DataQuery::apply를 비교하고, 모든 keyset 커서를 순회하며, 저장, 충돌, 고유 오류, 트랜잭션, 스트림을 검사합니다. 변수에 하나 이상의 URL(공백으로 구분)이 있을 때 실행되며, 그렇지 않으면 건너뛰었다고 알립니다.

docker run -d --name zeyn-pg -e POSTGRES_PASSWORD=zeyn -p 55432:5432 postgres:17-alpine
ZEYN_TEST_PG=postgres://postgres:zeyn@127.0.0.1:55432/postgres cargo test -p zeyn --features postgres --test postgres

docker run -d --name zeyn-ora11 -p 15211:1521 -e ORACLE_PASSWORD=zeyn -e APP_USER=zeyn -e APP_USER_PASSWORD=zeyn gvenzl/oracle-xe:11-slim
docker run -d --name zeyn-ora21 -p 15221:1521 -e ORACLE_PASSWORD=zeyn -e APP_USER=zeyn -e APP_USER_PASSWORD=zeyn gvenzl/oracle-xe:21-slim
ZEYN_TEST_ORACLE="oracle://zeyn:zeyn@127.0.0.1:15211/XE oracle://zeyn:zeyn@127.0.0.1:15221/XEPDB1" cargo test -p zeyn --features oracle --test oracle

Oracle 컨테이너는 시작하는 데 몇 분이 걸립니다. docker logs에서 DATABASE IS READY TO USE!를 기다리십시오.