backend/pkg/library/cassandra/crud.go

204 lines
7.4 KiB
Go
Raw Normal View History

2025-11-17 09:31:58 +00:00
package cassandra
import (
"context"
"reflect"
"github.com/gocql/gocql"
"github.com/scylladb/gocqlx/v3/qb"
"github.com/scylladb/gocqlx/v3/table"
)
var qh = &queryHelper{}
// Insert 依據 document 自動產生 INSERT 語句並執行
// keyspace 如果為空,則使用初始化時設定的預設 keyspace
func (db *CassandraDB) Insert(ctx context.Context, document any, keyspace string) error {
keyspace = getKeyspace(db, keyspace)
metadata, err := GenerateTableMetadata(document, keyspace)
if err != nil {
return err
}
t := table.New(metadata)
q := qh.withContextAndTimestamp(ctx, db.GetSession().Query(t.Insert()).BindStruct(document))
return q.ExecRelease()
}
// Get 根據 struct 的 Primary Key 查詢單筆資料Get ByPK
// - filter 為目標資料 struct其欄位需對應表格的 Primary Key 欄位Partition Key + Clustering Key
// - Cassandra 中 Primary Key 是由 Partition Key 與 Clustering Key 組成的整體,作為唯一識別一筆資料的 key
// - Cassandra 並不保證 Partition Key 或 Clustering Key 單獨具有唯一性,只有整個 Primary Key 才是唯一
// - Partition Key 的作用是將資料分布到不同節點NodeClustering Key 則是節點內排序資料用
// - 如果僅提供 Partition Key會查到分區內的多筆資料但由於 .Get() 預設加 LIMIT 1僅會取得其中一筆排序第一
// - 若想查詢特定欄位(如 name但該欄位不是 Primary Key 組成部分,則無法使用 .Get() 查詢,也無法用該欄位直接篩選資料(會報錯)
// - 解法是1. 改變 table 結構使欲查欄位成為 PK或 2. 建立額外 table 以該欄位為 Partition Key或 3. 使用 ALLOW FILTERING不建議
// Get 根據 struct 的 Primary Key 查詢單筆資料Get ByPK
// keyspace 如果為空,則使用初始化時設定的預設 keyspace
func (db *CassandraDB) Get(ctx context.Context, dest any, keyspace string) error {
keyspace = getKeyspace(db, keyspace)
metadata, err := GenerateTableMetadata(dest, keyspace)
if err != nil {
return err
}
t := table.New(metadata)
q := qh.withContextAndTimestamp(ctx, db.GetSession().Query(t.Get()).BindStruct(dest))
err = q.GetRelease(dest)
if err == gocql.ErrNotFound {
return ErrNotFound.WithTable(metadata.Name)
} else if err != nil {
return ErrInvalidInput.WithTable(metadata.Name).WithError(err)
}
return nil
}
// Delete 依據 document 的主鍵產生 DELETE 語句並執行
// keyspace 如果為空,則使用初始化時設定的預設 keyspace
func (db *CassandraDB) Delete(ctx context.Context, filter any, keyspace string) error {
keyspace = getKeyspace(db, keyspace)
metadata, err := GenerateTableMetadata(filter, keyspace)
if err != nil {
return err
}
t := table.New(metadata)
stmt, names := t.Delete()
q := qh.withContextAndTimestamp(ctx, db.GetSession().Query(stmt, names).BindStruct(filter))
return q.ExecRelease()
}
// Update 根據 document 欄位產生 UPDATE 語句並執行
// - 只會更新非零值或非 nil 的欄位(零值欄位會被排除)
// - 主鍵欄位一定會保留,作為 WHERE 條件使用
// keyspace 如果為空,則使用初始化時設定的預設 keyspace
func (db *CassandraDB) Update(ctx context.Context, document any, keyspace string) error {
return db.UpdateSelective(ctx, document, keyspace, false)
}
// UpdateSelective 根據 document 欄位產生 UPDATE 語句並執行
// - includeZero: false 時只更新非零值欄位(等同於 Updatetrue 時更新所有欄位(包括零值)
// - 主鍵欄位一定會保留,作為 WHERE 條件使用
// keyspace 如果為空,則使用初始化時設定的預設 keyspace
func (db *CassandraDB) UpdateSelective(ctx context.Context, document any, keyspace string, includeZero bool) error {
keyspace = getKeyspace(db, keyspace)
metadata, err := GenerateTableMetadata(document, keyspace)
if err != nil {
return err
}
v := reflect.ValueOf(document)
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
typ := v.Type()
// 收集更新欄位與其值(根據 includeZero 決定是否包含零值,保留主鍵)
setCols := make([]string, 0)
setVals := make([]any, 0)
whereCols := make([]string, 0)
whereVals := make([]any, 0)
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
tag := field.Tag.Get("db")
if tag == "" || tag == "-" {
continue
}
val := v.Field(i)
if !val.IsValid() {
continue
}
if contains(metadata.PartKey, tag) || contains(metadata.SortKey, tag) {
whereCols = append(whereCols, tag)
whereVals = append(whereVals, val.Interface())
continue
}
if !includeZero && isZero(val) {
continue
}
setCols = append(setCols, tag)
setVals = append(setVals, val.Interface())
}
if len(setCols) == 0 {
return ErrNoFieldsToUpdate.WithTable(metadata.Name)
}
// Build UPDATE statement
builder := qb.Update(metadata.Name).Set(setCols...)
for _, col := range whereCols {
builder = builder.Where(qb.Eq(col))
}
stmt, names := builder.ToCql()
setVals = append(setVals, whereVals...)
q := qh.withContextAndTimestamp(ctx, db.GetSession().Query(stmt, names).Bind(setVals...))
return q.ExecRelease()
}
// UpdateAll 更新所有欄位(包括零值)
// keyspace 如果為空,則使用初始化時設定的預設 keyspace
func (db *CassandraDB) UpdateAll(ctx context.Context, document any, keyspace string) error {
return db.UpdateSelective(ctx, document, keyspace, true)
}
// TODO: Cassandra 不支援 OFFSET 方式的分頁(例如查詢第 N 頁)
// 原因Cassandra 是分散式資料庫,設計上不允許像傳統 SQL 那樣用 OFFSET 跳頁,會導致效能極差
// ✅ 正確方式為使用 PagingState 做游標式Cursor-based分頁一頁一頁往後翻
// ✅ 如果需要快取第 N 頁位置,應在應用層儲存每一頁的 PagingState 以供跳轉
// ❌ Cassandra 不適合直接實作全站排行榜或全表分頁查詢,除非搭配 ElasticSearch 或針對 Partition Key 分頁設計
// 若未來有特定分區(如 user_id條件可考慮實作分區內的分頁邏輯以提高效能
// GetAll 取得指定 struct 類型在 Cassandra 中的所有資料
// - filter用來推斷 table 結構的範例物件(可為指標)
// - result要寫入的 slice 指標,如 *[]MyStruct
// keyspace 如果為空,則使用初始化時設定的預設 keyspace
func (db *CassandraDB) GetAll(ctx context.Context, filter any, result any, keyspace string) error {
keyspace = getKeyspace(db, keyspace)
metadata, err := GenerateTableMetadata(filter, keyspace)
if err != nil {
return err
}
t := table.New(metadata)
stmt, names := qb.Select(t.Name()).Columns(metadata.Columns...).ToCql()
q := qh.withContextAndTimestamp(ctx, db.GetSession().Query(stmt, names))
return q.SelectRelease(result)
}
// QueryBuilder executes a query with optional conditions on Cassandra table
// keyspace 如果為空,則使用初始化時設定的預設 keyspace
func (db *CassandraDB) QueryBuilder(
ctx context.Context,
tableStruct any,
result any,
keyspace string,
opts ...QueryOption,
) error {
keyspace = getKeyspace(db, keyspace)
metadata, err := GenerateTableMetadata(tableStruct, keyspace)
if err != nil {
return err
}
tbl := table.New(metadata)
builder := qb.Select(tbl.Name()).Columns(metadata.Columns...)
bindMap := qb.M{}
for _, opt := range opts {
opt(builder, bindMap)
}
stmt, names := builder.ToCql()
query := qh.withContextAndTimestamp(ctx, db.GetSession().Query(stmt, names).BindMap(bindMap))
return query.SelectRelease(result)
}