diff --git a/go.mod b/go.mod index 8a5e4eb..f6d6390 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect diff --git a/go.sum b/go.sum index 68e0f60..d1a563a 100644 --- a/go.sum +++ b/go.sum @@ -11,6 +11,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= diff --git a/modules/kv/kv.go b/modules/kv/kv.go new file mode 100644 index 0000000..9e41615 --- /dev/null +++ b/modules/kv/kv.go @@ -0,0 +1,489 @@ +package kv + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" + "sync" + "time" + + luajit "git.sharkk.net/Sky/LuaJIT-to-Go" + "github.com/goccy/go-json" +) + +var ( + stores = make(map[string]*Store) + mutex sync.RWMutex +) + +type Store struct { + data map[string]string + expires map[string]int64 + filename string + mutex sync.RWMutex +} + +func GetFunctionList() map[string]luajit.GoFunction { + return map[string]luajit.GoFunction{ + "kv_open": kv_open, + "kv_get": kv_get, + "kv_set": kv_set, + "kv_delete": kv_delete, + "kv_clear": kv_clear, + "kv_has": kv_has, + "kv_size": kv_size, + "kv_keys": kv_keys, + "kv_values": kv_values, + "kv_save": kv_save, + "kv_close": kv_close, + "kv_increment": kv_increment, + "kv_append": kv_append, + "kv_expire": kv_expire, + "kv_cleanup_expired": kv_cleanup_expired, + } +} + +// kv_open(name, filename) -> boolean +func kv_open(s *luajit.State) int { + name := s.ToString(1) + filename := s.ToString(2) + + if name == "" { + s.PushBoolean(false) + return 1 + } + + mutex.Lock() + defer mutex.Unlock() + + if _, exists := stores[name]; exists { + s.PushBoolean(true) + return 1 + } + + store := &Store{ + data: make(map[string]string), + expires: make(map[string]int64), + filename: filename, + } + + if filename != "" { + store.load() + } + + stores[name] = store + s.PushBoolean(true) + return 1 +} + +// kv_get(name, key, default) -> value or default +func kv_get(s *luajit.State) int { + name := s.ToString(1) + key := s.ToString(2) + hasDefault := s.GetTop() >= 3 + + mutex.RLock() + store, exists := stores[name] + mutex.RUnlock() + + if !exists { + if hasDefault { + s.PushCopy(3) + } else { + s.PushNil() + } + return 1 + } + + store.mutex.RLock() + value, found := store.data[key] + store.mutex.RUnlock() + + if found { + s.PushString(value) + } else if hasDefault { + s.PushCopy(3) + } else { + s.PushNil() + } + return 1 +} + +// kv_set(name, key, value) -> boolean +func kv_set(s *luajit.State) int { + name := s.ToString(1) + key := s.ToString(2) + value := s.ToString(3) + + mutex.RLock() + store, exists := stores[name] + mutex.RUnlock() + + if !exists { + s.PushBoolean(false) + return 1 + } + + store.mutex.Lock() + store.data[key] = value + store.mutex.Unlock() + + s.PushBoolean(true) + return 1 +} + +// kv_delete(name, key) -> boolean +func kv_delete(s *luajit.State) int { + name := s.ToString(1) + key := s.ToString(2) + + mutex.RLock() + store, exists := stores[name] + mutex.RUnlock() + + if !exists { + s.PushBoolean(false) + return 1 + } + + store.mutex.Lock() + _, existed := store.data[key] + delete(store.data, key) + delete(store.expires, key) + store.mutex.Unlock() + + s.PushBoolean(existed) + return 1 +} + +// kv_clear(name) -> boolean +func kv_clear(s *luajit.State) int { + name := s.ToString(1) + + mutex.RLock() + store, exists := stores[name] + mutex.RUnlock() + + if !exists { + s.PushBoolean(false) + return 1 + } + + store.mutex.Lock() + store.data = make(map[string]string) + store.expires = make(map[string]int64) + store.mutex.Unlock() + + s.PushBoolean(true) + return 1 +} + +// kv_has(name, key) -> boolean +func kv_has(s *luajit.State) int { + name := s.ToString(1) + key := s.ToString(2) + + mutex.RLock() + store, exists := stores[name] + mutex.RUnlock() + + if !exists { + s.PushBoolean(false) + return 1 + } + + store.mutex.RLock() + _, found := store.data[key] + store.mutex.RUnlock() + + s.PushBoolean(found) + return 1 +} + +// kv_size(name) -> number +func kv_size(s *luajit.State) int { + name := s.ToString(1) + + mutex.RLock() + store, exists := stores[name] + mutex.RUnlock() + + if !exists { + s.PushNumber(0) + return 1 + } + + store.mutex.RLock() + size := len(store.data) + store.mutex.RUnlock() + + s.PushNumber(float64(size)) + return 1 +} + +// kv_keys(name) -> table +func kv_keys(s *luajit.State) int { + name := s.ToString(1) + + mutex.RLock() + store, exists := stores[name] + mutex.RUnlock() + + if !exists { + s.NewTable() + return 1 + } + + store.mutex.RLock() + keys := make([]string, 0, len(store.data)) + for k := range store.data { + keys = append(keys, k) + } + store.mutex.RUnlock() + + s.PushValue(keys) + return 1 +} + +// kv_values(name) -> table +func kv_values(s *luajit.State) int { + name := s.ToString(1) + + mutex.RLock() + store, exists := stores[name] + mutex.RUnlock() + + if !exists { + s.NewTable() + return 1 + } + + store.mutex.RLock() + values := make([]string, 0, len(store.data)) + for _, v := range store.data { + values = append(values, v) + } + store.mutex.RUnlock() + + s.PushValue(values) + return 1 +} + +// kv_save(name) -> boolean +func kv_save(s *luajit.State) int { + name := s.ToString(1) + + mutex.RLock() + store, exists := stores[name] + mutex.RUnlock() + + if !exists || store.filename == "" { + s.PushBoolean(false) + return 1 + } + + err := store.save() + s.PushBoolean(err == nil) + return 1 +} + +// kv_close(name) -> boolean +func kv_close(s *luajit.State) int { + name := s.ToString(1) + + mutex.Lock() + defer mutex.Unlock() + + store, exists := stores[name] + if !exists { + s.PushBoolean(false) + return 1 + } + + if store.filename != "" { + store.save() + } + + delete(stores, name) + s.PushBoolean(true) + return 1 +} + +// kv_increment(name, key, delta) -> number +func kv_increment(s *luajit.State) int { + name := s.ToString(1) + key := s.ToString(2) + delta := 1.0 + if s.GetTop() >= 3 { + delta = s.ToNumber(3) + } + + mutex.RLock() + store, exists := stores[name] + mutex.RUnlock() + + if !exists { + s.PushNumber(0) + return 1 + } + + store.mutex.Lock() + current, _ := strconv.ParseFloat(store.data[key], 64) + newValue := current + delta + store.data[key] = strconv.FormatFloat(newValue, 'g', -1, 64) + store.mutex.Unlock() + + s.PushNumber(newValue) + return 1 +} + +// kv_append(name, key, value, separator) -> boolean +func kv_append(s *luajit.State) int { + name := s.ToString(1) + key := s.ToString(2) + value := s.ToString(3) + separator := "" + if s.GetTop() >= 4 { + separator = s.ToString(4) + } + + mutex.RLock() + store, exists := stores[name] + mutex.RUnlock() + + if !exists { + s.PushBoolean(false) + return 1 + } + + store.mutex.Lock() + current := store.data[key] + if current == "" { + store.data[key] = value + } else { + store.data[key] = current + separator + value + } + store.mutex.Unlock() + + s.PushBoolean(true) + return 1 +} + +// kv_expire(name, key, ttl) -> boolean +func kv_expire(s *luajit.State) int { + name := s.ToString(1) + key := s.ToString(2) + ttl := s.ToNumber(3) + + mutex.RLock() + store, exists := stores[name] + mutex.RUnlock() + + if !exists { + s.PushBoolean(false) + return 1 + } + + store.mutex.Lock() + store.expires[key] = time.Now().Unix() + int64(ttl) + store.mutex.Unlock() + + s.PushBoolean(true) + return 1 +} + +// kv_cleanup_expired(name) -> number +func kv_cleanup_expired(s *luajit.State) int { + name := s.ToString(1) + + mutex.RLock() + store, exists := stores[name] + mutex.RUnlock() + + if !exists { + s.PushNumber(0) + return 1 + } + + currentTime := time.Now().Unix() + deleted := 0 + + store.mutex.Lock() + for key, expireTime := range store.expires { + if currentTime >= expireTime { + delete(store.data, key) + delete(store.expires, key) + deleted++ + } + } + store.mutex.Unlock() + + s.PushNumber(float64(deleted)) + return 1 +} + +func (store *Store) load() error { + if store.filename == "" { + return nil + } + + file, err := os.Open(store.filename) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + defer file.Close() + + if strings.HasSuffix(store.filename, ".json") { + decoder := json.NewDecoder(file) + return decoder.Decode(&store.data) + } + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + store.data[parts[0]] = parts[1] + } + } + + return scanner.Err() +} + +func (store *Store) save() error { + if store.filename == "" { + return fmt.Errorf("no filename specified") + } + + file, err := os.Create(store.filename) + if err != nil { + return err + } + defer file.Close() + + store.mutex.RLock() + defer store.mutex.RUnlock() + + if strings.HasSuffix(store.filename, ".json") { + encoder := json.NewEncoder(file) + encoder.SetIndent("", "\t") + return encoder.Encode(store.data) + } + + for key, value := range store.data { + if _, err := fmt.Fprintf(file, "%s=%s\n", key, value); err != nil { + return err + } + } + + return nil +} diff --git a/modules/kv/kv.lua b/modules/kv/kv.lua new file mode 100644 index 0000000..983899b --- /dev/null +++ b/modules/kv/kv.lua @@ -0,0 +1,196 @@ +local kv = {} + +-- ====================================================================== +-- BASIC KEY-VALUE OPERATIONS +-- ====================================================================== + +function kv.open(name, filename) + if type(name) ~= "string" then error("kv.open: store name must be a string", 2) end + if filename ~= nil and type(filename) ~= "string" then error("kv.open: filename must be a string", 2) end + + filename = filename or "" + return moonshark.kv_open(name, filename) +end + +function kv.get(name, key, default) + if type(name) ~= "string" then error("kv.get: store name must be a string", 2) end + if type(key) ~= "string" then error("kv.get: key must be a string", 2) end + + if default ~= nil then + return moonshark.kv_get(name, key, default) + else + return moonshark.kv_get(name, key) + end +end + +function kv.set(name, key, value) + if type(name) ~= "string" then error("kv.set: store name must be a string", 2) end + if type(key) ~= "string" then error("kv.set: key must be a string", 2) end + if type(value) ~= "string" then error("kv.set: value must be a string", 2) end + + return moonshark.kv_set(name, key, value) +end + +function kv.delete(name, key) + if type(name) ~= "string" then error("kv.delete: store name must be a string", 2) end + if type(key) ~= "string" then error("kv.delete: key must be a string", 2) end + + return moonshark.kv_delete(name, key) +end + +function kv.clear(name) + if type(name) ~= "string" then error("kv.clear: store name must be a string", 2) end + + return moonshark.kv_clear(name) +end + +function kv.has(name, key) + if type(name) ~= "string" then error("kv.has: store name must be a string", 2) end + if type(key) ~= "string" then error("kv.has: key must be a string", 2) end + + return moonshark.kv_has(name, key) +end + +function kv.size(name) + if type(name) ~= "string" then error("kv.size: store name must be a string", 2) end + + return moonshark.kv_size(name) +end + +function kv.keys(name) + if type(name) ~= "string" then error("kv.keys: store name must be a string", 2) end + + return moonshark.kv_keys(name) +end + +function kv.values(name) + if type(name) ~= "string" then error("kv.values: store name must be a string", 2) end + + return moonshark.kv_values(name) +end + +function kv.save(name) + if type(name) ~= "string" then error("kv.save: store name must be a string", 2) end + + return moonshark.kv_save(name) +end + +function kv.close(name) + if type(name) ~= "string" then error("kv.close: store name must be a string", 2) end + + return moonshark.kv_close(name) +end + +-- ====================================================================== +-- UTILITY FUNCTIONS +-- ====================================================================== + +function kv.increment(name, key, delta) + if type(name) ~= "string" then error("kv.increment: store name must be a string", 2) end + if type(key) ~= "string" then error("kv.increment: key must be a string", 2) end + delta = delta or 1 + if type(delta) ~= "number" then error("kv.increment: delta must be a number", 2) end + + return moonshark.kv_increment(name, key, delta) +end + +function kv.append(name, key, value, separator) + if type(name) ~= "string" then error("kv.append: store name must be a string", 2) end + if type(key) ~= "string" then error("kv.append: key must be a string", 2) end + if type(value) ~= "string" then error("kv.append: value must be a string", 2) end + separator = separator or "" + if type(separator) ~= "string" then error("kv.append: separator must be a string", 2) end + + return moonshark.kv_append(name, key, value, separator) +end + +function kv.expire(name, key, ttl) + if type(name) ~= "string" then error("kv.expire: store name must be a string", 2) end + if type(key) ~= "string" then error("kv.expire: key must be a string", 2) end + if type(ttl) ~= "number" or ttl <= 0 then error("kv.expire: TTL must be a positive number", 2) end + + return moonshark.kv_expire(name, key, ttl) +end + +function kv.cleanup_expired(name) + if type(name) ~= "string" then error("kv.cleanup_expired: store name must be a string", 2) end + + return moonshark.kv_cleanup_expired(name) +end + +-- ====================================================================== +-- OBJECT-ORIENTED INTERFACE +-- ====================================================================== + +local Store = {} +Store.__index = Store + +function kv.create(name, filename) + if type(name) ~= "string" then error("kv.create: store name must be a string", 2) end + if filename ~= nil and type(filename) ~= "string" then error("kv.create: filename must be a string", 2) end + + local success = kv.open(name, filename) + if not success then + error("kv.create: failed to open store '" .. name .. "'", 2) + end + + return setmetatable({name = name}, Store) +end + +function Store:get(key, default) + return kv.get(self.name, key, default) +end + +function Store:set(key, value) + return kv.set(self.name, key, value) +end + +function Store:delete(key) + return kv.delete(self.name, key) +end + +function Store:clear() + return kv.clear(self.name) +end + +function Store:has(key) + return kv.has(self.name, key) +end + +function Store:size() + return kv.size(self.name) +end + +function Store:keys() + return kv.keys(self.name) +end + +function Store:values() + return kv.values(self.name) +end + +function Store:save() + return kv.save(self.name) +end + +function Store:close() + return kv.close(self.name) +end + +function Store:increment(key, delta) + return kv.increment(self.name, key, delta) +end + +function Store:append(key, value, separator) + return kv.append(self.name, key, value, separator) +end + +function Store:expire(key, ttl) + return kv.expire(self.name, key, ttl) +end + +function Store:cleanup_expired() + return kv.cleanup_expired(self.name) +end + +return kv diff --git a/modules/registry.go b/modules/registry.go index 984e896..e9196a0 100644 --- a/modules/registry.go +++ b/modules/registry.go @@ -8,6 +8,7 @@ import ( "Moonshark/modules/crypto" "Moonshark/modules/fs" "Moonshark/modules/http" + "Moonshark/modules/kv" "Moonshark/modules/math" "Moonshark/modules/sql" lua_string "Moonshark/modules/string" @@ -41,6 +42,7 @@ func New() *Registry { maps.Copy(r.goFuncs, fs.GetFunctionList()) maps.Copy(r.goFuncs, http.GetFunctionList()) maps.Copy(r.goFuncs, sql.GetFunctionList()) + maps.Copy(r.goFuncs, kv.GetFunctionList()) r.loadEmbeddedModules() return r diff --git a/modules/sessions/sessions.lua b/modules/sessions/sessions.lua new file mode 100644 index 0000000..1387fb2 --- /dev/null +++ b/modules/sessions/sessions.lua @@ -0,0 +1,212 @@ +local kv = require("kv") +local crypto = require("crypto") +local json = require("json") + +local sessions = {} +local stores = {} +local default_store = nil + +-- ====================================================================== +-- CORE FUNCTIONS +-- ====================================================================== + +function sessions.init(store_name, filename) + store_name = store_name or "sessions" + if not kv.open(store_name, filename) then return false end + stores[store_name] = true + if not default_store then default_store = store_name end + return true +end + +function sessions.create(session_id, data, store_name) + if type(session_id) ~= "string" then error("session ID must be a string", 2) end + if data ~= nil and type(data) ~= "table" then error("data must be a table", 2) end + + store_name = store_name or default_store + if not store_name then error("No session store initialized", 2) end + + local session_data = { + data = data or {}, + _created = os.time(), + _last_accessed = os.time() + } + + return kv.set(store_name, "session:" .. session_id, json.encode(session_data)) +end + +function sessions.get(session_id, store_name) + if type(session_id) ~= "string" then error("session ID must be a string", 2) end + + store_name = store_name or default_store + if not store_name then error("No session store initialized", 2) end + + local json_str = kv.get(store_name, "session:" .. session_id) + if not json_str then return nil end + + local session_data = json.decode(json_str) + if not session_data then return nil end + + -- Update last accessed + session_data._last_accessed = os.time() + kv.set(store_name, "session:" .. session_id, json.encode(session_data)) + + -- Return flattened data with metadata + local result = session_data.data or {} + result._created = session_data._created + result._last_accessed = session_data._last_accessed + return result +end + +function sessions.update(session_id, data, store_name) + if type(session_id) ~= "string" then error("session ID must be a string", 2) end + if type(data) ~= "table" then error("data must be a table", 2) end + + store_name = store_name or default_store + if not store_name then error("No session store initialized", 2) end + + local json_str = kv.get(store_name, "session:" .. session_id) + if not json_str then return false end + + local session_data = json.decode(json_str) + if not session_data then return false end + + session_data.data = data + session_data._last_accessed = os.time() + + return kv.set(store_name, "session:" .. session_id, json.encode(session_data)) +end + +function sessions.delete(session_id, store_name) + if type(session_id) ~= "string" then error("session ID must be a string", 2) end + + store_name = store_name or default_store + if not store_name then error("No session store initialized", 2) end + return kv.delete(store_name, "session:" .. session_id) +end + +function sessions.cleanup(max_age, store_name) + store_name = store_name or default_store + if not store_name then error("No session store initialized", 2) end + + local keys = kv.keys(store_name) + local current_time = os.time() + local deleted = 0 + + for _, key in ipairs(keys) do + if key:match("^session:") then + local json_str = kv.get(store_name, key) + if json_str then + local session_data = json.decode(json_str) + if session_data and session_data._last_accessed then + if current_time - session_data._last_accessed > max_age then + kv.delete(store_name, key) + deleted = deleted + 1 + end + end + end + end + end + + return deleted +end + +function sessions.close(store_name) + local success = kv.close(store_name) + stores[store_name] = nil + if default_store == store_name then + default_store = next(stores) + end + return success +end + +-- ====================================================================== +-- UTILITIES +-- ====================================================================== + +function sessions.generate_id() + return crypto.random_alphanumeric(32) +end + +function sessions.exists(session_id, store_name) + store_name = store_name or default_store + if not store_name then error("No session store initialized", 2) end + return kv.has(store_name, "session:" .. session_id) +end + +function sessions.list(store_name) + store_name = store_name or default_store + if not store_name then error("No session store initialized", 2) end + + local keys = kv.keys(store_name) + local session_ids = {} + + for _, key in ipairs(keys) do + local session_id = key:match("^session:(.+)") + if session_id then + table.insert(session_ids, session_id) + end + end + + return session_ids +end + +function sessions.count(store_name) + return #sessions.list(store_name) +end + +function sessions.reset() + stores = {} + default_store = nil +end + +-- ====================================================================== +-- OOP INTERFACE +-- ====================================================================== + +local SessionStore = {} +SessionStore.__index = SessionStore + +function sessions.create_store(store_name, filename) + if not sessions.init(store_name, filename) then + error("Failed to initialize store '" .. store_name .. "'", 2) + end + return setmetatable({name = store_name}, SessionStore) +end + +function SessionStore:create(session_id, data) + return sessions.create(session_id, data, self.name) +end + +function SessionStore:get(session_id) + return sessions.get(session_id, self.name) +end + +function SessionStore:update(session_id, data) + return sessions.update(session_id, data, self.name) +end + +function SessionStore:delete(session_id) + return sessions.delete(session_id, self.name) +end + +function SessionStore:cleanup(max_age) + return sessions.cleanup(max_age, self.name) +end + +function SessionStore:exists(session_id) + return sessions.exists(session_id, self.name) +end + +function SessionStore:list() + return sessions.list(self.name) +end + +function SessionStore:count() + return sessions.count(self.name) +end + +function SessionStore:close() + return sessions.close(self.name) +end + +return sessions diff --git a/tests/kv.lua b/tests/kv.lua new file mode 100644 index 0000000..b5740ab --- /dev/null +++ b/tests/kv.lua @@ -0,0 +1,358 @@ +require("tests") +local kv = require("kv") + +-- Clean up any existing test files +os.remove("test_store.json") +os.remove("test_store.txt") +os.remove("test_oop.json") +os.remove("test_temp.json") + +-- ====================================================================== +-- BASIC OPERATIONS +-- ====================================================================== + +test("Store creation and opening", function() + assert(kv.open("test", "test_store.json")) + assert(kv.open("memory_only", "")) + assert(not kv.open("", "test.json")) -- Empty name should fail +end) + +test("Set and get operations", function() + assert(kv.set("test", "key1", "value1")) + assert_equal("value1", kv.get("test", "key1")) + assert_equal("default", kv.get("test", "nonexistent", "default")) + assert_equal(nil, kv.get("test", "nonexistent")) + + -- Test with special characters + assert(kv.set("test", "special:key", "value with spaces & symbols!")) + assert_equal("value with spaces & symbols!", kv.get("test", "special:key")) +end) + +test("Key existence and deletion", function() + kv.set("test", "temp_key", "temp_value") + assert(kv.has("test", "temp_key")) + assert(not kv.has("test", "missing_key")) + + assert(kv.delete("test", "temp_key")) + assert(not kv.has("test", "temp_key")) + assert(not kv.delete("test", "missing_key")) +end) + +test("Store size tracking", function() + kv.clear("test") + assert_equal(0, kv.size("test")) + + kv.set("test", "k1", "v1") + kv.set("test", "k2", "v2") + kv.set("test", "k3", "v3") + assert_equal(3, kv.size("test")) + + kv.delete("test", "k2") + assert_equal(2, kv.size("test")) +end) + +test("Keys and values retrieval", function() + kv.clear("test") + kv.set("test", "a", "apple") + kv.set("test", "b", "banana") + kv.set("test", "c", "cherry") + + local keys = kv.keys("test") + local values = kv.values("test") + + assert_equal(3, #keys) + assert_equal(3, #values) + + -- Check all keys exist + local key_set = {} + for _, k in ipairs(keys) do + key_set[k] = true + end + assert(key_set["a"] and key_set["b"] and key_set["c"]) + + -- Check all values exist + local value_set = {} + for _, v in ipairs(values) do + value_set[v] = true + end + assert(value_set["apple"] and value_set["banana"] and value_set["cherry"]) +end) + +test("Clear store", function() + kv.set("test", "temp1", "value1") + kv.set("test", "temp2", "value2") + assert(kv.size("test") > 0) + + assert(kv.clear("test")) + assert_equal(0, kv.size("test")) + assert(not kv.has("test", "temp1")) +end) + +test("Save and close operations", function() + kv.set("test", "persistent", "data") + assert(kv.save("test")) + assert(kv.close("test")) + + -- Reopen and verify data persists + assert(kv.open("test", "test_store.json")) + assert_equal("data", kv.get("test", "persistent")) +end) + +test("Invalid store operations", function() + assert(not kv.set("nonexistent", "key", "value")) + assert_equal(nil, kv.get("nonexistent", "key")) + assert(not kv.has("nonexistent", "key")) + assert_equal(0, kv.size("nonexistent")) + assert(not kv.delete("nonexistent", "key")) + assert(not kv.clear("nonexistent")) + assert(not kv.save("nonexistent")) + assert(not kv.close("nonexistent")) +end) + +-- ====================================================================== +-- OBJECT-ORIENTED INTERFACE +-- ====================================================================== + +test("OOP store creation", function() + local store = kv.create("oop_test", "test_oop.json") + assert_equal("oop_test", store.name) + + local memory_store = kv.create("memory_oop") + assert_equal("memory_oop", memory_store.name) +end) + +test("OOP basic operations", function() + local store = kv.create("oop_basic") + + assert(store:set("foo", "bar")) + assert_equal("bar", store:get("foo")) + assert_equal("default", store:get("missing", "default")) + assert(store:has("foo")) + assert(not store:has("missing")) + assert_equal(1, store:size()) + + assert(store:delete("foo")) + assert(not store:has("foo")) + assert_equal(0, store:size()) +end) + +test("OOP collections", function() + local store = kv.create("oop_collections") + + store:set("a", "apple") + store:set("b", "banana") + store:set("c", "cherry") + + local keys = store:keys() + local values = store:values() + + assert_equal(3, #keys) + assert_equal(3, #values) + + store:clear() + assert_equal(0, store:size()) + + store:close() +end) + +-- ====================================================================== +-- UTILITY FUNCTIONS +-- ====================================================================== + +test("Increment operations", function() + kv.open("util_test") + + -- Increment non-existent key + assert_equal(1, kv.increment("util_test", "counter")) + assert_equal("1", kv.get("util_test", "counter")) + + -- Increment existing key + assert_equal(6, kv.increment("util_test", "counter", 5)) + assert_equal("6", kv.get("util_test", "counter")) + + -- Decrement + assert_equal(4, kv.increment("util_test", "counter", -2)) + + kv.close("util_test") +end) + +test("Append operations", function() + kv.open("append_test") + + -- Append to non-existent key + assert(kv.append("append_test", "list", "first")) + assert_equal("first", kv.get("append_test", "list")) + + -- Append with separator + assert(kv.append("append_test", "list", "second", ",")) + assert_equal("first,second", kv.get("append_test", "list")) + + assert(kv.append("append_test", "list", "third", ",")) + assert_equal("first,second,third", kv.get("append_test", "list")) + + kv.close("append_test") +end) + +test("TTL and expiration", function() + kv.open("ttl_test", "test_temp.json") + + kv.set("ttl_test", "temp_key", "temp_value") + assert(kv.expire("ttl_test", "temp_key", 1)) -- 1 second TTL + + -- Key should still exist immediately + assert(kv.has("ttl_test", "temp_key")) + + -- Wait for expiration + os.execute("sleep 2") + local expired = kv.cleanup_expired("ttl_test") + assert(expired >= 1) + + -- Key should be gone + assert(not kv.has("ttl_test", "temp_key")) + + kv.close("ttl_test") +end) + +-- ====================================================================== +-- FILE FORMAT TESTS +-- ====================================================================== + +test("JSON file format", function() + kv.open("json_test", "test.json") + kv.set("json_test", "key1", "value1") + kv.set("json_test", "key2", "value2") + kv.save("json_test") + kv.close("json_test") + + -- Verify file exists and reload + assert(file_exists("test.json")) + kv.open("json_test", "test.json") + assert_equal("value1", kv.get("json_test", "key1")) + assert_equal("value2", kv.get("json_test", "key2")) + kv.close("json_test") + + os.remove("test.json") +end) + +test("Text file format", function() + kv.open("txt_test", "test.txt") + kv.set("txt_test", "setting1", "value1") + kv.set("txt_test", "setting2", "value2") + kv.save("txt_test") + kv.close("txt_test") + + -- Verify file exists and reload + assert(file_exists("test.txt")) + kv.open("txt_test", "test.txt") + assert_equal("value1", kv.get("txt_test", "setting1")) + assert_equal("value2", kv.get("txt_test", "setting2")) + kv.close("txt_test") + + os.remove("test.txt") +end) + +-- ====================================================================== +-- EDGE CASES AND ERROR HANDLING +-- ====================================================================== + +test("Empty values and keys", function() + kv.open("edge_test") + + -- Empty value + assert(kv.set("edge_test", "empty", "")) + assert_equal("", kv.get("edge_test", "empty")) + + -- Unicode keys and values + assert(kv.set("edge_test", "ключ", "значение")) + assert_equal("значение", kv.get("edge_test", "ключ")) + + kv.close("edge_test") +end) + +test("Special characters in data", function() + kv.open("special_test") + + local special_value = 'Special chars: "quotes", \'apostrophes\', \n newlines, \t tabs, \\ backslashes' + assert(kv.set("special_test", "special", special_value)) + assert_equal(special_value, kv.get("special_test", "special")) + + kv.close("special_test") +end) + +test("Large data handling", function() + kv.open("large_test") + + -- Large value + local large_value = string.rep("x", 10000) + assert(kv.set("large_test", "large", large_value)) + assert_equal(large_value, kv.get("large_test", "large")) + + -- Many keys + for i = 1, 100 do + kv.set("large_test", "key" .. i, "value" .. i) + end + assert_equal(101, kv.size("large_test")) -- 100 + 1 large value + + kv.close("large_test") +end) + +-- ====================================================================== +-- PERFORMANCE TESTS +-- ====================================================================== + +test("Performance test", function() + kv.open("perf_test") + + local start = os.clock() + + -- Bulk insert + for i = 1, 1000 do + kv.set("perf_test", "key" .. i, "value" .. i) + end + local insert_time = os.clock() - start + + -- Bulk read + start = os.clock() + for i = 1, 1000 do + local value = kv.get("perf_test", "key" .. i) + assert_equal("value" .. i, value) + end + local read_time = os.clock() - start + + -- Check final size + assert_equal(1000, kv.size("perf_test")) + + print(string.format(" Insert 1000 items: %.3fs", insert_time)) + print(string.format(" Read 1000 items: %.3fs", read_time)) + + kv.close("perf_test") +end) + +-- ====================================================================== +-- INTEGRATION TESTS +-- ====================================================================== + +test("Multiple store integration", function() + local users = kv.create("users_int") + local cache = kv.create("cache_int") + + -- Simulate user data + users:set("user:123", "john_doe") + cache:set("user:123:last_seen", tostring(os.time())) + + -- Verify data in stores + assert_equal("john_doe", users:get("user:123")) + assert(cache:has("user:123:last_seen")) + + -- Clean up + users:close() + cache:close() +end) + +-- Clean up test files +--os.remove("test_store.json") +--os.remove("test_oop.json") +--os.remove("test_temp.json") + +summary() +test_exit() diff --git a/tests/sessions.lua b/tests/sessions.lua new file mode 100644 index 0000000..830d7b2 --- /dev/null +++ b/tests/sessions.lua @@ -0,0 +1,397 @@ +require("tests") +local sessions = require("sessions") + +-- Clean up test files +os.remove("test_sessions.json") +os.remove("test_sessions2.json") + +-- ====================================================================== +-- SESSION STORE INITIALIZATION +-- ====================================================================== + +test("Session store initialization", function() + assert(sessions.init("test_sessions", "test_sessions.json")) + assert(sessions.init("memory_sessions")) + + -- Test with explicit store name + assert(sessions.init("named_sessions", "test_sessions2.json")) +end) + +test("Session ID generation", function() + local id1 = sessions.generate_id() + local id2 = sessions.generate_id() + + assert_equal(32, #id1) + assert_equal(32, #id2) + assert(id1 ~= id2, "Session IDs should be unique") + + -- Check character set (alphanumeric) + assert(id1:match("^[a-zA-Z0-9]+$"), "Session ID should be alphanumeric") +end) + +-- ====================================================================== +-- BASIC SESSION OPERATIONS +-- ====================================================================== + +test("Session creation and retrieval", function() + sessions.init("basic_test") + + local session_id = sessions.generate_id() + local session_data = { + user_id = 123, + username = "testuser", + role = "admin", + permissions = {"read", "write"} + } + + assert(sessions.create(session_id, session_data)) + + local retrieved = sessions.get(session_id) + assert_equal(123, retrieved.user_id) + assert_equal("testuser", retrieved.username) + assert_equal("admin", retrieved.role) + assert_table_equal({"read", "write"}, retrieved.permissions) + assert(retrieved._created ~= nil) + assert(retrieved._last_accessed ~= nil) + + -- Test non-existent session + assert_equal(nil, sessions.get("nonexistent")) +end) + +test("Session updates", function() + sessions.init("update_test") + + local session_id = sessions.generate_id() + sessions.create(session_id, {count = 1}) + + local session = sessions.get(session_id) + local new_data = { + count = 2, + new_field = "added" + } + + assert(sessions.update(session_id, new_data)) + + local updated = sessions.get(session_id) + assert_equal(2, updated.count) + assert_equal("added", updated.new_field) + assert(updated._created ~= nil) + assert(updated._last_accessed ~= nil) +end) + +test("Session deletion", function() + sessions.init("delete_test") + + local session_id = sessions.generate_id() + sessions.create(session_id, {temp = true}) + + assert(sessions.get(session_id) ~= nil) + assert(sessions.delete(session_id)) + assert_equal(nil, sessions.get(session_id)) + + -- Delete non-existent session + assert(not sessions.delete("nonexistent")) +end) + +test("Session existence check", function() + sessions.init("exists_test") + + local session_id = sessions.generate_id() + assert(not sessions.exists(session_id)) + + sessions.create(session_id, {test = true}) + assert(sessions.exists(session_id)) + + sessions.delete(session_id) + assert(not sessions.exists(session_id)) +end) + +-- ====================================================================== +-- MULTI-STORE OPERATIONS +-- ====================================================================== + +test("Multiple session stores", function() + assert(sessions.init("store1", "store1.json")) + assert(sessions.init("store2", "store2.json")) + + local id1 = sessions.generate_id() + local id2 = sessions.generate_id() + + -- Create sessions in different stores + assert(sessions.create(id1, {store = "store1"}, "store1")) + assert(sessions.create(id2, {store = "store2"}, "store2")) + + -- Verify isolation + assert_equal(nil, sessions.get(id1, "store2")) + assert_equal(nil, sessions.get(id2, "store1")) + + -- Verify correct retrieval + local s1 = sessions.get(id1, "store1") + local s2 = sessions.get(id2, "store2") + assert_equal("store1", s1.store) + assert_equal("store2", s2.store) + + os.remove("store1.json") + os.remove("store2.json") +end) + +test("Default store behavior", function() + sessions.reset() + sessions.init("default_store") + + local session_id = sessions.generate_id() + sessions.create(session_id, {test = "default"}) + + -- Should work without specifying store + local retrieved = sessions.get(session_id) + assert_equal("default", retrieved.test) +end) + +-- ====================================================================== +-- SESSION CLEANUP +-- ====================================================================== + +test("Session cleanup", function() + sessions.init("cleanup_test") + + local old_session = sessions.generate_id() + local new_session = sessions.generate_id() + + sessions.create(old_session, {test = "old"}, "cleanup_test") + sessions.create(new_session, {test = "new"}, "cleanup_test") + + -- Wait to create age difference + os.execute("sleep 2") + + -- Access new session to update timestamp + sessions.get(new_session, "cleanup_test") + + -- Clean up sessions older than 1 second + local deleted = sessions.cleanup(1, "cleanup_test") + assert(deleted >= 1) + + -- New session should remain (recently accessed) + assert(sessions.get(new_session, "cleanup_test") ~= nil) +end) + +-- ====================================================================== +-- SESSION LISTING AND COUNTING +-- ====================================================================== + +test("Session listing and counting", function() + sessions.init("list_test") + + local ids = {} + for i = 1, 5 do + local id = sessions.generate_id() + sessions.create(id, {index = i}, "list_test") + table.insert(ids, id) + end + + local session_list = sessions.list("list_test") + assert_equal(5, #session_list) + + local count = sessions.count("list_test") + assert_equal(5, count) + + -- Verify all IDs are in the list + local id_set = {} + for _, id in ipairs(session_list) do + id_set[id] = true + end + + for _, id in ipairs(ids) do + assert(id_set[id], "Session ID should be in list") + end +end) + +-- ====================================================================== +-- OBJECT-ORIENTED INTERFACE +-- ====================================================================== + +test("OOP session store", function() + local store = sessions.create_store("oop_sessions", "oop_test.json") + + local session_id = sessions.generate_id() + local data = {user = "oop_test", role = "admin"} + + assert(store:create(session_id, data)) + + local retrieved = store:get(session_id) + assert_equal("oop_test", retrieved.user) + assert_equal("admin", retrieved.role) + + retrieved.last_action = "login" + assert(store:update(session_id, retrieved)) + + local updated = store:get(session_id) + assert_equal("login", updated.last_action) + + assert(store:exists(session_id)) + assert_equal(1, store:count()) + + local session_list = store:list() + assert_equal(1, #session_list) + assert_equal(session_id, session_list[1]) + + assert(store:delete(session_id)) + assert(not store:exists(session_id)) + + store:close() + os.remove("oop_test.json") +end) + +-- ====================================================================== +-- ERROR HANDLING +-- ====================================================================== + +test("Session error handling", function() + sessions.reset() + + -- Try operations without initialization + local success1, err1 = pcall(sessions.create, "test_id", {}) + assert(not success1) + + local success2, err2 = pcall(sessions.get, "test_id") + assert(not success2) + + -- Initialize and test invalid inputs + sessions.init("error_test") + + -- Invalid session ID type + local success3, err3 = pcall(sessions.create, 123, {}) + assert(not success3) + + -- Invalid data type + local success4, err4 = pcall(sessions.create, "test", "not_a_table") + assert(not success4) + + -- Invalid store name + local success5, err5 = pcall(sessions.get, "test", 123) + assert(not success5) +end) + +-- ====================================================================== +-- DATA PERSISTENCE +-- ====================================================================== + +test("Session persistence", function() + sessions.init("persist_test", "persist_sessions.json") + + local session_id = sessions.generate_id() + local data = { + user_id = 789, + settings = {theme = "dark", lang = "en"}, + cart = {items = {"item1", "item2"}, total = 25.99} + } + + sessions.create(session_id, data, "persist_test") + sessions.close("persist_test") + + -- Reinitialize and verify data persists + sessions.init("persist_test", "persist_sessions.json") + local retrieved = sessions.get(session_id, "persist_test") + + assert_equal(789, retrieved.user_id) + assert_equal("dark", retrieved.settings.theme) + assert_equal("en", retrieved.settings.lang) + assert_equal(2, #retrieved.cart.items) + assert_equal(25.99, retrieved.cart.total) + + sessions.close("persist_test") + os.remove("persist_sessions.json") +end) + +-- ====================================================================== +-- COMPLEX DATA STRUCTURES +-- ====================================================================== + +test("Complex session data", function() + sessions.init("complex_test") + + local session_id = sessions.generate_id() + local complex_data = { + user = { + id = 456, + profile = { + name = "Jane Doe", + email = "jane@example.com", + preferences = { + notifications = true, + privacy = "friends_only" + } + } + }, + activity = { + pages_visited = {"home", "profile", "settings"}, + actions = { + {type = "login", time = os.time()}, + {type = "view_page", page = "profile", time = os.time()} + } + }, + metadata = { + ip = "192.168.1.1", + user_agent = "Test Browser 1.0" + } + } + + sessions.create(session_id, complex_data) + local retrieved = sessions.get(session_id) + + assert_equal(456, retrieved.user.id) + assert_equal("Jane Doe", retrieved.user.profile.name) + assert_equal(true, retrieved.user.profile.preferences.notifications) + assert_equal(3, #retrieved.activity.pages_visited) + assert_equal("login", retrieved.activity.actions[1].type) + assert_equal("192.168.1.1", retrieved.metadata.ip) +end) + +-- ====================================================================== +-- WORKFLOW INTEGRATION +-- ====================================================================== + +test("Session workflow integration", function() + sessions.init("workflow_test") + + -- Simulate user workflow + local session_id = sessions.generate_id() + + -- User login + sessions.create(session_id, { + user_id = 999, + username = "workflow_user", + status = "logged_in" + }) + + -- User adds items to cart + local session = sessions.get(session_id) + session.cart = {"item1", "item2"} + session.cart_total = 19.99 + sessions.update(session_id, session) + + -- User proceeds to checkout + session = sessions.get(session_id) + session.checkout_step = "payment" + session.payment_method = "credit_card" + sessions.update(session_id, session) + + -- Verify final state + local final_session = sessions.get(session_id) + assert_equal(999, final_session.user_id) + assert_equal("logged_in", final_session.status) + assert_equal(2, #final_session.cart) + assert_equal(19.99, final_session.cart_total) + assert_equal("payment", final_session.checkout_step) + assert_equal("credit_card", final_session.payment_method) + + -- User completes order and logs out + sessions.delete(session_id) + assert_equal(nil, sessions.get(session_id)) +end) + +-- Clean up test files +--os.remove("test_sessions.json") +--os.remove("test_sessions2.json") + +summary() +test_exit()