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) }