feat: Initial project setup and core API functionality
This commit introduces the initial structure for the data platform, including:
- **Core Structure:** Setup of basic Go modules, environment, and project layers (cmd, internal, pkg).
- **SQLite Store:** Implements data persistence using SQLite, including schema initialization (collections/records).
- **CRUD Operations:** Full C.R.U.D. logic for records within collections.
- **Dynamic Querying:** Implements advanced query features over JSON data:
- **Filtering:** Dynamic filters (eq, gt, lt, etc.) on JSON fields using SQL casting (`CAST(... AS REAL)`).
- **Pagination:** Support for `limit` and `offset` query parameters.
- **Sorting:** Dynamic sorting based on JSON fields (`orderBy=field` or `orderBy=-field`).
This commit is contained in:
parent
3c9cba1018
commit
4704d7802b
|
|
@ -0,0 +1,145 @@
|
||||||
|
// cmd/main.go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"upupa_dataist_ir/internal/api"
|
||||||
|
"upupa_dataist_ir/internal/store"
|
||||||
|
"upupa_dataist_ir/pkg/models"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DB_FILE = "upupa_dataist.db"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.Println("Initializing database...")
|
||||||
|
|
||||||
|
dbStore, err := store.NewSQLiteStore(DB_FILE)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to connect to database: %v", err)
|
||||||
|
}
|
||||||
|
log.Println("Database connection successful.")
|
||||||
|
|
||||||
|
if err := dbStore.InitSchema(); err != nil {
|
||||||
|
log.Fatalf("Failed to initialize database schema: %v", err)
|
||||||
|
}
|
||||||
|
log.Println("Database schema initialized.")
|
||||||
|
|
||||||
|
if err := createSystemCollections(dbStore); err != nil {
|
||||||
|
log.Fatalf("Failed to create system collections: %v", err)
|
||||||
|
}
|
||||||
|
log.Println("System collections created successfully.")
|
||||||
|
|
||||||
|
if err := createProductCollection(dbStore, dbStore); err != nil {
|
||||||
|
log.Fatalf("Failed to create product records: %v", err)
|
||||||
|
}
|
||||||
|
log.Println("Product collection and test record created successfully.")
|
||||||
|
|
||||||
|
apiService := api.NewAPI(dbStore, dbStore)
|
||||||
|
router := chi.NewRouter()
|
||||||
|
|
||||||
|
router.Get("/api/health", apiService.HealthCheck)
|
||||||
|
|
||||||
|
router.Get("/api/collections/{name}/records/{id}", apiService.GetRecordByIDHandler)
|
||||||
|
router.Put("/api/collections/{name}/records/{id}", apiService.UpdateRecordHandler)
|
||||||
|
router.Delete("/api/collections/{name}/records/{id}", apiService.DeleteRecordHandler)
|
||||||
|
|
||||||
|
router.Get("/api/collections/{name}/records", apiService.GetAllRecordsHandler)
|
||||||
|
router.Post("/api/collections/{name}/records", apiService.CreateRecordHandler)
|
||||||
|
|
||||||
|
httpServer := &http.Server{
|
||||||
|
Addr: ":8081",
|
||||||
|
Handler: router,
|
||||||
|
ReadTimeout: 5 * time.Second,
|
||||||
|
WriteTimeout: 10 * time.Second,
|
||||||
|
IdleTimeout: 120 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Server initialized. Starting API server on http://localhost:8081")
|
||||||
|
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Fatalf("Could not start server: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSystemCollections(repo store.CollectionRepository) error {
|
||||||
|
usersCollectionID := "r6k495b5i2n2x8k"
|
||||||
|
|
||||||
|
_, err := repo.GetByName("users")
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
|
||||||
|
userCollection := models.Collection{
|
||||||
|
ID: usersCollectionID,
|
||||||
|
Name: "users",
|
||||||
|
System: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Creating system collection 'users'...")
|
||||||
|
if _, err := repo.CreateCollection(userCollection); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createProductCollection(colRepo store.CollectionRepository, recRepo store.RecordRepository) error {
|
||||||
|
productCollectionID := "t1t8t8p8c8c6v5s"
|
||||||
|
productID := "a4g7m2c9s3f1h0j"
|
||||||
|
|
||||||
|
_, err := colRepo.GetByName("products")
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
|
||||||
|
productCollection := models.Collection{
|
||||||
|
ID: productCollectionID,
|
||||||
|
Name: "products",
|
||||||
|
System: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Creating collection 'products'...")
|
||||||
|
if _, err := colRepo.CreateCollection(productCollection); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
productData := map[string]interface{}{
|
||||||
|
"name": "تست",
|
||||||
|
"price": 49.99,
|
||||||
|
"stock": 100,
|
||||||
|
"releaseDate": time.Now().Format("2006-01-02"),
|
||||||
|
}
|
||||||
|
|
||||||
|
newRecord := models.Record{
|
||||||
|
ID: productID,
|
||||||
|
CollectionID: productCollectionID,
|
||||||
|
Data: productData,
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Adding test product record...")
|
||||||
|
|
||||||
|
if _, err := recRepo.CreateRecord(newRecord); err != nil {
|
||||||
|
|
||||||
|
if err.Error() == "UNIQUE constraint failed: records.id" ||
|
||||||
|
err.Error() == "UNIQUE constraint failed: records.collection_id, records.id" {
|
||||||
|
log.Println("Test record already exists, skipping creation.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to create record: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
module upupa_dataist_ir
|
||||||
|
|
||||||
|
go 1.24.6
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-chi/chi/v5 v5.2.3 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.32 // indirect
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
|
@ -0,0 +1,251 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"upupa_dataist_ir/internal/store"
|
||||||
|
"upupa_dataist_ir/pkg/models"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type API struct {
|
||||||
|
Collections store.CollectionRepository
|
||||||
|
Records store.RecordRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAPI(colRepo store.CollectionRepository, recRepo store.RecordRepository) *API {
|
||||||
|
return &API{
|
||||||
|
Collections: colRepo,
|
||||||
|
Records: recRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) HealthCheck(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("upupa_dataist_ir is running!"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) CreateRecordHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
collectionName := chi.URLParam(r, "name")
|
||||||
|
|
||||||
|
collection, err := a.Collections.GetByName(collectionName)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
http.Error(w, fmt.Sprintf("Collection '%s' not found.", collectionName), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("Error retrieving collection: %v", err)
|
||||||
|
http.Error(w, "Database error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var recordData map[string]interface{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&recordData); err != nil {
|
||||||
|
http.Error(w, "Invalid JSON payload", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newRecord := models.Record{
|
||||||
|
ID: models.NewID(),
|
||||||
|
CollectionID: collection.ID,
|
||||||
|
Data: recordData,
|
||||||
|
}
|
||||||
|
|
||||||
|
createdRecord, err := a.Records.CreateRecord(newRecord)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error creating record: %v", err)
|
||||||
|
http.Error(w, "Failed to create record", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode(createdRecord)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) GetAllRecordsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
collectionName := chi.URLParam(r, "name")
|
||||||
|
|
||||||
|
collection, err := a.Collections.GetByName(collectionName)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
http.Error(w, fmt.Sprintf("Collection '%s' not found.", collectionName), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("Error retrieving collection: %v", err)
|
||||||
|
http.Error(w, "Database error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
queryParams := r.URL.Query()
|
||||||
|
filters := make(map[string]interface{})
|
||||||
|
|
||||||
|
for key, values := range queryParams {
|
||||||
|
if key != "limit" && key != "offset" && key != "orderBy" && len(values) > 0 {
|
||||||
|
filters[key] = values[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var finalFilters map[string]interface{} = nil
|
||||||
|
if len(filters) > 0 {
|
||||||
|
finalFilters = filters
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := getIntQueryParam(queryParams.Get("limit"), 50)
|
||||||
|
offset := getIntQueryParam(queryParams.Get("offset"), 0)
|
||||||
|
orderBy := queryParams.Get("orderBy")
|
||||||
|
|
||||||
|
if limit < 0 || limit > 100 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
records, err := a.Records.GetAll(collection.ID, finalFilters, orderBy, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error retrieving records: %v", err)
|
||||||
|
http.Error(w, "Failed to retrieve records", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"items": records,
|
||||||
|
"totalItems": len(records),
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
"orderBy": orderBy,
|
||||||
|
}
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) GetRecordByIDHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
collectionName := chi.URLParam(r, "name")
|
||||||
|
recordID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
collection, err := a.Collections.GetByName(collectionName)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
http.Error(w, fmt.Sprintf("Collection '%s' not found.", collectionName), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("Error retrieving collection: %v", err)
|
||||||
|
http.Error(w, "Database error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := a.Records.GetByID(collection.ID, recordID)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
http.Error(w, fmt.Sprintf("Record with ID '%s' not found.", recordID), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("Error retrieving record: %v", err)
|
||||||
|
http.Error(w, "Failed to retrieve record", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) UpdateRecordHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
collectionName := chi.URLParam(r, "name")
|
||||||
|
recordID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
collection, err := a.Collections.GetByName(collectionName)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
http.Error(w, fmt.Sprintf("Collection '%s' not found.", collectionName), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("Error retrieving collection: %v", err)
|
||||||
|
http.Error(w, "Database error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var updatedData map[string]interface{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&updatedData); err != nil {
|
||||||
|
http.Error(w, "Invalid JSON payload", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
recordToUpdate := models.Record{
|
||||||
|
ID: recordID,
|
||||||
|
CollectionID: collection.ID,
|
||||||
|
Data: updatedData,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.Records.Update(recordToUpdate); err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
http.Error(w, fmt.Sprintf("Record with ID '%s' not found.", recordID), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("Error updating record: %v", err)
|
||||||
|
http.Error(w, "Failed to update record", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedRecord, err := a.Records.GetByID(collection.ID, recordID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error fetching updated record: %v", err)
|
||||||
|
http.Error(w, "Record updated, but failed to fetch final data.", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode(updatedRecord)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) DeleteRecordHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
collectionName := chi.URLParam(r, "name")
|
||||||
|
recordID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
collection, err := a.Collections.GetByName(collectionName)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
http.Error(w, fmt.Sprintf("Collection '%s' not found.", collectionName), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("Error retrieving collection: %v", err)
|
||||||
|
http.Error(w, "Database error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.Records.Delete(collection.ID, recordID); err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
http.Error(w, fmt.Sprintf("Record with ID '%s' not found.", recordID), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("Error deleting record: %v", err)
|
||||||
|
http.Error(w, "Failed to delete record", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getIntQueryParam(param string, defaultValue int) int {
|
||||||
|
if param == "" {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
val, err := strconv.Atoi(param)
|
||||||
|
if err != nil {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
// internal/store/repository.go
|
||||||
|
package store
|
||||||
|
|
||||||
|
import "upupa_dataist_ir/pkg/models"
|
||||||
|
|
||||||
|
type CollectionRepository interface {
|
||||||
|
GetByName(name string) (models.Collection, error)
|
||||||
|
CreateCollection(c models.Collection) (models.Collection, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecordRepository interface {
|
||||||
|
CreateRecord(r models.Record) (models.Record, error)
|
||||||
|
|
||||||
|
GetAll(collectionID string, filters map[string]interface{}, orderBy string, limit, offset int) ([]models.Record, error)
|
||||||
|
|
||||||
|
GetByID(collectionID, recordID string) (models.Record, error)
|
||||||
|
Update(r models.Record) error
|
||||||
|
Delete(collectionID, recordID string) error
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,330 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"upupa_dataist_ir/pkg/models"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SQLiteStore struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSQLiteStore(dbPath string) (*SQLiteStore, error) {
|
||||||
|
db, err := sql.Open("sqlite3", dbPath+"?_loc=UTC")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &SQLiteStore{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLiteStore) InitSchema() error {
|
||||||
|
query := `
|
||||||
|
CREATE TABLE IF NOT EXISTS collections (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
system BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS records (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
collection_id TEXT NOT NULL,
|
||||||
|
created DATETIME DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
|
updated DATETIME DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (collection_id) REFERENCES collections (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_records_collection ON records (collection_id);
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := s.db.Exec(query)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLiteStore) GetByName(name string) (models.Collection, error) {
|
||||||
|
query := "SELECT id, name, system FROM collections WHERE name = ?"
|
||||||
|
|
||||||
|
var c models.Collection
|
||||||
|
row := s.db.QueryRow(query, name)
|
||||||
|
|
||||||
|
err := row.Scan(&c.ID, &c.Name, &c.System)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLiteStore) CreateCollection(c models.Collection) (models.Collection, error) {
|
||||||
|
query := `INSERT INTO collections (id, name, system) VALUES (?, ?, ?)`
|
||||||
|
|
||||||
|
_, err := s.db.Exec(query, c.ID, c.Name, c.System)
|
||||||
|
if err != nil {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLiteStore) CreateRecord(r models.Record) (models.Record, error) {
|
||||||
|
jsonData, err := marshalRecordData(r.Data)
|
||||||
|
if err != nil {
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `INSERT INTO records
|
||||||
|
(id, collection_id, data)
|
||||||
|
VALUES (?, ?, ?)`
|
||||||
|
|
||||||
|
_, err = s.db.Exec(query, r.ID, r.CollectionID, jsonData)
|
||||||
|
if err != nil {
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.GetByID(r.CollectionID, r.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLiteStore) GetAll(collectionID string, filters map[string]interface{}, orderBy string, limit, offset int) ([]models.Record, error) {
|
||||||
|
|
||||||
|
baseQuery := `SELECT id, collection_id, created, updated, data FROM records WHERE collection_id = ?`
|
||||||
|
|
||||||
|
args := []interface{}{collectionID}
|
||||||
|
|
||||||
|
if len(filters) > 0 {
|
||||||
|
filterClause := ""
|
||||||
|
|
||||||
|
for key, value := range filters {
|
||||||
|
field, op, err := parseFilterKey(key)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Ignoring invalid filter key: %s, Error: %v", key, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
caster := getFieldCaster(field)
|
||||||
|
|
||||||
|
filterClause += fmt.Sprintf(" AND %s %s ?", caster, op)
|
||||||
|
args = append(args, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseQuery += filterClause
|
||||||
|
}
|
||||||
|
|
||||||
|
sortClause := " ORDER BY created DESC"
|
||||||
|
if orderBy != "" {
|
||||||
|
sortClause = buildOrderClause(orderBy)
|
||||||
|
}
|
||||||
|
baseQuery += sortClause
|
||||||
|
|
||||||
|
if limit > 0 {
|
||||||
|
baseQuery += fmt.Sprintf(" LIMIT %d OFFSET %d", limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.db.Query(baseQuery, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
records := []models.Record{}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
r models.Record
|
||||||
|
dataStr string
|
||||||
|
)
|
||||||
|
|
||||||
|
err := rows.Scan(&r.ID, &r.CollectionID, &r.Created, &r.Updated, &dataStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := unmarshalRecordData(dataStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Data = data
|
||||||
|
records = append(records, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLiteStore) GetByID(collectionID, recordID string) (models.Record, error) {
|
||||||
|
query := `SELECT id, collection_id, created, updated, data
|
||||||
|
FROM records
|
||||||
|
WHERE collection_id = ? AND id = ?`
|
||||||
|
|
||||||
|
row := s.db.QueryRow(query, collectionID, recordID)
|
||||||
|
|
||||||
|
var (
|
||||||
|
r models.Record
|
||||||
|
dataStr string
|
||||||
|
)
|
||||||
|
|
||||||
|
err := row.Scan(&r.ID, &r.CollectionID, &r.Created, &r.Updated, &dataStr)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := unmarshalRecordData(dataStr)
|
||||||
|
if err != nil {
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Data = data
|
||||||
|
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLiteStore) Update(r models.Record) error {
|
||||||
|
jsonData, err := marshalRecordData(r.Data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `UPDATE records SET data = ?, updated = (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) WHERE collection_id = ? AND id = ?`
|
||||||
|
|
||||||
|
result, err := s.db.Exec(query, jsonData, r.CollectionID, r.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if rowsAffected == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLiteStore) Delete(collectionID, recordID string) error {
|
||||||
|
query := `DELETE FROM records WHERE collection_id = ? AND id = ?`
|
||||||
|
|
||||||
|
result, err := s.db.Exec(query, collectionID, recordID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if rowsAffected == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildOrderClause(orderBy string) string {
|
||||||
|
orderBy = strings.TrimSpace(orderBy)
|
||||||
|
if orderBy == "" {
|
||||||
|
return " ORDER BY created DESC"
|
||||||
|
}
|
||||||
|
|
||||||
|
field := orderBy
|
||||||
|
direction := "ASC"
|
||||||
|
|
||||||
|
if strings.HasPrefix(orderBy, "-") {
|
||||||
|
field = orderBy[1:]
|
||||||
|
direction = "DESC"
|
||||||
|
}
|
||||||
|
|
||||||
|
caster := getFieldCaster(field)
|
||||||
|
|
||||||
|
return fmt.Sprintf(" ORDER BY %s %s", caster, direction)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFieldCaster(field string) string {
|
||||||
|
if field == "price" || field == "stock" {
|
||||||
|
return fmt.Sprintf("CAST(json_extract(data, '$.%s') AS REAL)", field)
|
||||||
|
}
|
||||||
|
|
||||||
|
if field == "releaseDate" {
|
||||||
|
return fmt.Sprintf("JULIANDAY(json_extract(data, '$.%s'))", field)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("json_extract(data, '$.%s')", field)
|
||||||
|
}
|
||||||
|
|
||||||
|
var validOperators = map[string]string{
|
||||||
|
"eq": "=",
|
||||||
|
"gt": ">",
|
||||||
|
"lt": "<",
|
||||||
|
"gte": ">=",
|
||||||
|
"lte": "<=",
|
||||||
|
"neq": "!=",
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFilterKey(key string) (field string, op string, err error) {
|
||||||
|
start := -1
|
||||||
|
end := -1
|
||||||
|
for i, r := range key {
|
||||||
|
if r == '[' {
|
||||||
|
start = i
|
||||||
|
} else if r == ']' {
|
||||||
|
end = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if start == -1 || end == -1 || end <= start+1 || end != len(key)-1 {
|
||||||
|
return "", "", fmt.Errorf("invalid filter format: %s. Expected field[op]", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
operatorCode := key[start+1 : end]
|
||||||
|
operator, ok := validOperators[operatorCode]
|
||||||
|
if !ok {
|
||||||
|
return "", "", fmt.Errorf("invalid operator: %s", operatorCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
field = key[:start]
|
||||||
|
if field == "" {
|
||||||
|
return "", "", fmt.Errorf("field name cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
return field, operator, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalRecordData(data map[string]interface{}) (string, error) {
|
||||||
|
jsonData, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to marshal record data: %w", err)
|
||||||
|
}
|
||||||
|
return string(jsonData), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func unmarshalRecordData(dataStr string) (map[string]interface{}, error) {
|
||||||
|
var data map[string]interface{}
|
||||||
|
err := json.Unmarshal([]byte(dataStr), &data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal record data: %w", err)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
// pkg/models/models.go
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Collection struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
System bool `json:"system"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Record struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CollectionID string `json:"collectionId"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
Updated time.Time `json:"updated"`
|
||||||
|
Data map[string]interface{} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewID() string {
|
||||||
|
b := make([]byte, 6)
|
||||||
|
rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue