183 lines
6.2 KiB
Go
183 lines
6.2 KiB
Go
package cassandra
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"reflect"
|
||
"time"
|
||
|
||
"github.com/gocql/gocql"
|
||
"github.com/scylladb/gocqlx/v3/qb"
|
||
"github.com/scylladb/gocqlx/v3/table"
|
||
)
|
||
|
||
var ErrNotFound = fmt.Errorf("not found")
|
||
|
||
// Insert 依據 document 自動產生 INSERT 語句並執行
|
||
func (db *CassandraDB) Insert(ctx context.Context, document any, keyspace string) error {
|
||
metadata, err := GenerateTableMetadata(document, keyspace)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
t := table.New(metadata)
|
||
q := db.GetSession().Query(t.Insert()).BindStruct(document).WithContext(ctx).WithTimestamp(time.Now().UnixNano() / 1e3)
|
||
err = q.ExecRelease()
|
||
|
||
return err
|
||
}
|
||
|
||
// 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 的作用是將資料分布到不同節點(Node),Clustering Key 則是節點內排序資料用
|
||
// - 如果僅提供 Partition Key,會查到分區內的多筆資料,但由於 .Get() 預設加 LIMIT 1,僅會取得其中一筆(排序第一)
|
||
// - 若想查詢特定欄位(如 name)但該欄位不是 Primary Key 組成部分,則無法使用 .Get() 查詢,也無法用該欄位直接篩選資料(會報錯)
|
||
// - 解法是:1. 改變 table 結構使欲查欄位成為 PK,或 2. 建立額外 table 以該欄位為 Partition Key,或 3. 使用 ALLOW FILTERING(不建議)
|
||
|
||
func (db *CassandraDB) Get(ctx context.Context, dest any, keyspace string) error {
|
||
metadata, err := GenerateTableMetadata(dest, keyspace)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
t := table.New(metadata)
|
||
q := db.GetSession().Query(t.Get()).BindStruct(dest).WithContext(ctx).WithTimestamp(time.Now().UnixNano() / 1e3)
|
||
err = q.GetRelease(dest)
|
||
|
||
if errors.Is(err, gocql.ErrNotFound) {
|
||
return ErrNotFound
|
||
} else if err != nil {
|
||
return err
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// Delete 依據 document 的主鍵產生 DELETE 語句並執行
|
||
func (db *CassandraDB) Delete(ctx context.Context, filter any, keyspace string) error {
|
||
metadata, err := GenerateTableMetadata(filter, keyspace)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
t := table.New(metadata)
|
||
stmt, names := t.Delete()
|
||
q := db.GetSession().Query(stmt, names).BindStruct(filter).WithContext(ctx).WithTimestamp(time.Now().UnixNano() / 1e3)
|
||
|
||
return q.ExecRelease()
|
||
}
|
||
|
||
// Update 根據 document 欄位產生 UPDATE 語句並執行
|
||
// - 只會更新非零值或非 nil 的欄位(零值欄位會被排除)
|
||
// - 主鍵欄位一定會保留,作為 WHERE 條件使用
|
||
// Update 根據 document 產生 UPDATE 語句並執行(只更新非零值欄位,保留主鍵)
|
||
func (db *CassandraDB) Update(ctx context.Context, document any, keyspace string) error {
|
||
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()
|
||
|
||
// 收集更新欄位與其值(排除零值,保留主鍵)
|
||
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 isZero(val) {
|
||
continue
|
||
}
|
||
|
||
setCols = append(setCols, tag)
|
||
setVals = append(setVals, val.Interface())
|
||
}
|
||
|
||
if len(setCols) == 0 {
|
||
return fmt.Errorf("no non-zero update fields provided")
|
||
}
|
||
|
||
// Build UPDATE statement
|
||
builder := qb.Update(metadata.Name).Set(setCols...)
|
||
for _, col := range whereCols {
|
||
builder = builder.Where(qb.Eq(col))
|
||
}
|
||
stmt, names := builder.ToCql()
|
||
|
||
args := append(setVals, whereVals...)
|
||
q := db.GetSession().Query(stmt, names).Bind(args...).WithContext(ctx).WithTimestamp(time.Now().UnixNano() / 1e3)
|
||
|
||
return q.ExecRelease()
|
||
}
|
||
|
||
// TODO: Cassandra 不支援 OFFSET 方式的分頁(例如查詢第 N 頁)
|
||
// 原因:Cassandra 是分散式資料庫,設計上不允許像傳統 SQL 那樣用 OFFSET 跳頁,會導致效能極差
|
||
// ✅ 正確方式為使用 PagingState 做游標式(Cursor-based)分頁,一頁一頁往後翻
|
||
// ✅ 如果需要快取第 N 頁位置,應在應用層儲存每一頁的 PagingState 以供跳轉
|
||
// ❌ Cassandra 不適合直接實作全站排行榜或全表分頁查詢,除非搭配 ElasticSearch 或針對 Partition Key 分頁設計
|
||
// 若未來有特定分區(如 user_id)條件,可考慮實作分區內的分頁邏輯以提高效能
|
||
|
||
// GetAll 取得指定 struct 類型在 Cassandra 中的所有資料
|
||
// - structInstance:用來推斷 table 結構的範例物件(可為指標)
|
||
// - result:要寫入的 slice 指標,如 *[]MyStruct
|
||
func (db *CassandraDB) GetAll(ctx context.Context, filter any, result any, keyspace string) error {
|
||
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 := db.GetSession().Query(stmt, names).WithContext(ctx).WithTimestamp(time.Now().UnixNano() / 1e3)
|
||
|
||
return q.SelectRelease(result)
|
||
}
|
||
|
||
// QueryBuilder executes a query with optional conditions on Cassandra table
|
||
func (db *CassandraDB) QueryBuilder(
|
||
ctx context.Context,
|
||
tableStruct any,
|
||
result any,
|
||
keyspace string,
|
||
opts ...QueryOption,
|
||
) error {
|
||
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 := db.GetSession().Query(stmt, names).WithContext(ctx).BindMap(bindMap).WithTimestamp(time.Now().UnixNano() / 1e3)
|
||
|
||
return query.SelectRelease(result)
|
||
}
|