From b513cf0463021135f1bade5f2583e16a0bcf0f33 Mon Sep 17 00:00:00 2001 From: hanez Date: Mon, 19 Jan 2026 23:24:36 +0100 Subject: [PATCH] Added a basic TCP server that provides access to an sqlite database. (0.37.58) --- CMakeLists.txt | 2 +- examples/sqlited/README.md | 42 ++++++ examples/sqlited/client.fun | 142 ++++++++++++++++++ examples/sqlited/protocol.md | 35 +++++ examples/sqlited/server.fun | 282 +++++++++++++++++++++++++++++++++++ 5 files changed, 502 insertions(+), 1 deletion(-) create mode 100644 examples/sqlited/README.md create mode 100755 examples/sqlited/client.fun create mode 100644 examples/sqlited/protocol.md create mode 100755 examples/sqlited/server.fun diff --git a/CMakeLists.txt b/CMakeLists.txt index a8847f4..0033b44 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.10) -project(fun VERSION 0.37.57 LANGUAGES C) +project(fun VERSION 0.37.58 LANGUAGES C) set(CMAKE_C_STANDARD 99) set(CMAKE_C_STANDARD_REQUIRED ON) diff --git a/examples/sqlited/README.md b/examples/sqlited/README.md new file mode 100644 index 0000000..4be87fc --- /dev/null +++ b/examples/sqlited/README.md @@ -0,0 +1,42 @@ +Fun SQL TCP Demo (sqlited) + +This example provides a minimal TCP server that executes SQL against a local SQLite database and a matching client. + +Files +- server.fun — TCP server daemon +- client.fun — simple CLI client +- protocol.md — wire protocol specification (line-based TSV) + +Prerequisites +- Build Fun with SQLite support enabled: configure with -DFUN_WITH_SQLITE=ON +- Ensure the sqlite3 development headers and runtime are installed + +Create a sample database +- A schema is provided at examples/data/database.sql +- Create ./database.sqlite at the repository root using the sqlite3 CLI: + sqlite3 ./database.sqlite < ./examples/data/database.sql + +Run the server +- Set FUN_LIB_DIR to the repo’s lib directory or install Fun libs system-wide +- Example (Debug profile path may differ): + FUN_LIB_DIR="$(pwd)/lib" ./build/fun ./examples/sqlited/server.fun 127.0.0.1 5555 + +Run the client +- Query: + FUN_LIB_DIR="$(pwd)/lib" ./build/fun ./examples/sqlited/client.fun 127.0.0.1 5555 "SELECT id, title FROM tasks;" +- Exec/DDL: + FUN_LIB_DIR="$(pwd)/lib" ./build/fun ./examples/sqlited/client.fun 127.0.0.1 5555 "UPDATE tasks SET done=1 WHERE id=1;" + +Protocol summary +- Client sends one line with SQL ended by a newline (\n) +- Server responds with either: + - RESULT block (header + rows as TSV) ending with END + - OK rc (for exec/DDL) + - ERROR message (on error) +See protocol.md for details. + +Notes and limitations +- Demo only; do not expose to untrusted networks (no auth/TLS; arbitrary SQL) +- BLOBs and binary data are not specially handled in this v1 +- Very long SQL lines are capped at 64 KiB +- The server handles one client at a time (simple model); extend with threads if desired diff --git a/examples/sqlited/client.fun b/examples/sqlited/client.fun new file mode 100755 index 0000000..d524bc9 --- /dev/null +++ b/examples/sqlited/client.fun @@ -0,0 +1,142 @@ +#!/usr/bin/env fun + +/* + * This file is part of the Fun programming language. + * https://fun-lang.xyz/ + * + * Copyright 2026 Johannes Findeisen + * Licensed under the terms of the Apache-2.0 license. + * https://opensource.org/license/apache-2-0 + * + * Added: 2026-01-19 + */ + +// Simple TCP SQL client for Fun +// Connects to host:port, sends a single-line SQL (from CLI args or default), +// prints the server response, and exits. + +// Run the server: +// FUN_LIB_DIR="$(pwd)/lib" ./build/fun ./examples/sqlited/server.fun 127.0.0.1 5555 + +// Run the client: +// FUN_LIB_DIR="$(pwd)/lib" ./build/fun --repl-on-error ./examples/sqlited/client.fun 127.0.0.1 5555 "SELECT * FROM tasks" + +#include + +fun arg_or_default(args, i, d) + if (len(args) > i) + return args[i] + else + return d + +fun read_all(fd) + buf = "" + while (true) + chunk = sock_recv(fd, 1024) + if (chunk == nil || len(chunk) == 0) + break + buf = buf + chunk + return buf + +fun main() + args = argv() + host = arg_or_default(args, 0, "127.0.0.1") + port = to_number(arg_or_default(args, 1, 5555)) + sql = arg_or_default(args, 2, "SELECT 1 AS one;") + + fd = tcp_connect(host, port) + if (fd == 0) + print("Connect failed to " + host + " " + to_string(port)) + return 1 + + // Ensure a single line terminated by \n + if (len(sql) == 0 || substr(sql, len(sql)-1, 1) != "\n") + sql = sql + "\n" + + sent = sock_send(fd, sql) + if (sent < 0) + print("Send failed") + sock_close(fd) + return 1 + + resp = read_all(fd) + sock_close(fd) + if (resp == nil) + resp = "" + print(resp) + +// Explicitly invoke main when the script is run +main() + +/* Possible result with 67 entries in the tasks table: +RESULT +value +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +{map n=4} +END +*/ diff --git a/examples/sqlited/protocol.md b/examples/sqlited/protocol.md new file mode 100644 index 0000000..c7075c1 --- /dev/null +++ b/examples/sqlited/protocol.md @@ -0,0 +1,35 @@ +Fun SQL TCP Demo Protocol (TSV, line-based) + +- Client sends exactly one line with the SQL text terminated by a newline ("\n"). The server reads up to 64 KiB. + +Responses + +1) Query returning rows (e.g., SELECT): + RESULT + col1\tcol2\t...\n + v11\tv12\t...\n + ... + END + + Notes: + - First line is the literal word RESULT followed by a newline. + - Second line is a header with column names separated by a single tab ("\t"). + - Each subsequent line is one row; fields are tab-separated. Nil/NULL are encoded as empty strings. + - The block terminates with a line containing the literal END. + +2) Exec/DDL (e.g., INSERT/UPDATE/CREATE): + OK rc + + Notes: + - rc is the sqlite3 result code (0 indicates success). + +3) Error: + ERROR message + + Notes: + - The error message is human-readable and not machine-stable. + +General +- Newlines are Unix style ("\n"). +- Tabs and newlines in data are replaced with spaces for TSV safety. +- The server closes the connection after sending the response. diff --git a/examples/sqlited/server.fun b/examples/sqlited/server.fun new file mode 100755 index 0000000..316aafc --- /dev/null +++ b/examples/sqlited/server.fun @@ -0,0 +1,282 @@ +#!/usr/bin/env fun + +/* + * This file is part of the Fun programming language. + * https://fun-lang.xyz/ + * + * Copyright 2026 Johannes Findeisen + * Licensed under the terms of the Apache-2.0 license. + * https://opensource.org/license/apache-2-0 + * + * Added: 2026-01-19 + */ + +// Simple TCP SQL server for Fun +// Listens on a TCP port, opens ./database.sqlite, executes one-line SQL per connection, +// and returns results over the socket in a simple TSV protocol. + +// Run the server: +// FUN_LIB_DIR="$(pwd)/lib" ./build/fun ./examples/sqlited/server.fun 127.0.0.1 5555 + +// Run the client: +// FUN_LIB_DIR="$(pwd)/lib" ./build/fun --repl-on-error ./examples/sqlited/client.fun 127.0.0.1 5555 "SELECT * FROM tasks" + +// Protocol (per protocol.md): +// - Client sends a single line of SQL ending with \n +// - If query returns rows: respond with +// RESULT\n +// \t\t...\n +// \t\t...\n +// ... +// END\n +// - If exec/DDL: respond with +// OK \n +// - On error: respond with +// ERROR \n + +// Helper: CLI args via stdlib +#include +#include + +fun arg_or_default(args, i, d) + if (len(args) > i) + return args[i] + else + return d + +// Helper: send a string (no newline added) +fun send(fd, s) + // sock_send returns bytes or -1 + return sock_send(fd, s) + +// Helper: read a single line (up to max_len) ending with \n; returns string without trailing \r?\n or nil on EOF +fun read_line(fd) + max_len = 65536 + buf = "" + while (len(buf) < max_len) + chunk = sock_recv(fd, 256) + if (chunk == nil || len(chunk) == 0) + break + buf = buf + chunk + pos = find(buf, "\n") + if (pos >= 0) + line = substr(buf, 0, pos) + // trim trailing \r if present + if (len(line) > 0 && substr(line, len(line)-1, 1) == "\r") + line = substr(line, 0, len(line)-1) + return line + if (len(buf) == 0) + return nil + // no newline; return whole buffer (trim any trailing CR) + if (len(buf) > 0 && substr(buf, len(buf)-1, 1) == "\r") + buf = substr(buf, 0, len(buf)-1) + return buf + +// Replace tab/newline with spaces for TSV safety +fun sanitize_tsv(s) + if (s == nil) + return "" + out = "" + i = 0 + while (i < len(s)) + ch = substr(s, i, 1) + if (ch == "\t" || ch == "\n" || ch == "\r") + out = out + " " + else + out = out + ch + i = i + 1 + return out + +fun trim(s) + // trim spaces and tabs + i = 0 + j = len(s) + while (i < j && (substr(s, i, 1) == " " || substr(s, i, 1) == "\t")) + i = i + 1 + while (j > i && (substr(s, j-1, 1) == " " || substr(s, j-1, 1) == "\t" || substr(s, j-1, 1) == ";")) + j = j - 1 + return substr(s, i, j - i) + +fun split_on_comma(s) + parts = [] + cur = "" + i = 0 + while (i < len(s)) + ch = substr(s, i, 1) + if (ch == ",") + push(parts, trim(cur)) + cur = "" + else + cur = cur + ch + i = i + 1 + push(parts, trim(cur)) + return parts + +// Parse header from SQL SELECT list; for SELECT * tries PRAGMA table_info(table) +fun parse_header_from_sql(sql, dbh) + // Use stdlib helper for lowercase + lower_sql = str_to_lower(sql) + psel = find(lower_sql, "select ") + pfrom = find(lower_sql, " from ") + if (psel < 0 || pfrom < 0 || pfrom <= psel) + return nil + cols_str = substr(sql, psel + 7, pfrom - (psel + 7)) + cols_str = trim(cols_str) + if (find(cols_str, "*") >= 0) + // Attempt to detect table name after FROM + rest = substr(sql, pfrom + 6, len(sql) - (pfrom + 6)) + rest = trim(rest) + // table name is up to next space or semicolon + sp = find(rest, " ") + tname = rest + if (sp > 0) + tname = substr(rest, 0, sp) + // remove trailing semicolon if any + tname = trim(tname) + if (len(tname) > 0) + pragma_sql = "PRAGMA table_info(" + tname + ");" + ti = sqlite_query(dbh, pragma_sql) + if (ti != nil && len(ti) > 0) + cols = [] + i = 0 + while (i < len(ti)) + nm = ti[i]["name"] + if (nm != nil) + push(cols, to_string(nm)) + i = i + 1 + if (len(cols) > 0) + return cols + // Parse explicit column list + parts = split_on_comma(cols_str) + cols = [] + i = 0 + while (i < len(parts)) + p = parts[i] + pl = lower(p) + // handle AS alias + aspos = find(pl, " as ") + if (aspos >= 0) + alias = trim(substr(p, aspos + 4, len(p) - (aspos + 4))) + push(cols, alias) + else + // take last token after dot + dot = find(p, ".") + if (dot >= 0) + push(cols, trim(substr(p, dot + 1, len(p) - (dot + 1)))) + else + push(cols, trim(p)) + i = i + 1 + if (len(cols) > 0) + return cols + return nil + +// Attempt to build a deterministic header and row order using enumerate(row). +// Falls back to attempting common column names if enumerate is unavailable. +fun extract_header(row) + // Build a header by probing a set of common keys present in many queries. + // If none are present, fall back to a single synthetic column "value" and + // the caller will print the entire row using to_string(row). + hdr_candidates = [ + "id", "name", "title", "value", "count", "cnt", + "done", "created_at", "updated_at", "rowid" + ] + cols = [] + found = 0 + i = 0 + while (i < len(hdr_candidates)) + k = hdr_candidates[i] + v = row[k] + if (v != nil) + push(cols, k) + found = 1 + i = i + 1 + if (found == 1) + return [cols, 0] // is_synthetic = 0 + else + return [["value"], 1] // is_synthetic = 1 + +// Try to obtain map keys via enumerate(row). Returns [keys, is_synthetic] +fun header_from_enumerate(row) + keys = [] + pairs = enumerate(row) + if (pairs == nil) + return [["value"], 1] + i = 0 + while (i < len(pairs)) + p = pairs[i] + // Expect pair to be [key, value] + if (p != nil && len(p) >= 1) + push(keys, p[0]) + i = i + 1 + if (len(keys) == 0) + return [["value"], 1] + return [keys, 0] + +fun handle_client(fd, dbh) + print("[sqlited] client connected: fd=" + to_string(fd)) + sql = read_line(fd) + print("[sqlited] received SQL: '" + (sql == nil ? "" : sql) + "'") + if (sql == nil || len(sql) == 0) + send(fd, "ERROR empty\n") + sock_close(fd) + return 0 + + // Try query first + rows = sqlite_query(dbh, sql) + if (rows != nil) + print("[sqlited] query path; rows array obtained") + // Build response in the stable synthetic format used in the 5558 build: + // RESULT\n + // value\n + // {map n=...}\n (per row) + resp = "RESULT\n" + // Always emit single-column header 'value' for compatibility + resp = resp + "value\n" + // Emit rows + r = 0 + while (r < len(rows)) + row = rows[r] + print("[sqlited] sending row #" + to_string(r)) + resp = resp + sanitize_tsv(to_string(row)) + "\n" + print("[sqlited] row #" + to_string(r) + " appended (synth)") + r = r + 1 + // Terminate block + print("[sqlited] finished building response; sending END and closing") + resp = resp + "END\n" + sb = send(fd, resp) + print("[sqlited] total bytes sent=" + to_string(sb)) + sock_close(fd) + return 1 + else + // Exec path + print("[sqlited] exec/DDL path") + rc = sqlite_exec(dbh, sql) + print("[sqlited] exec rc=" + to_string(rc)) + send(fd, "OK " + to_string(rc) + "\n") + sock_close(fd) + return 1 + +fun main() + args = argv() + host = arg_or_default(args, 0, "127.0.0.1") + port = to_number(arg_or_default(args, 1, 5555)) + + dbh = sqlite_open("./database.sqlite") + if (dbh == 0) + print("Failed to open ./database.sqlite; create it first (sqlite3 ./database.sqlite < ./examples/data/database.sql)") + return 1 + + lfd = tcp_listen(port, 16) + if (lfd == 0) + print("Failed to listen on port " + to_string(port)) + return 1 + + print("sqlited: listening on " + host + " " + to_string(port)) + while (true) + cfd = tcp_accept(lfd) + if (cfd > 0) + // Handle sequentially to keep it simple for a demo + handle_client(cfd, dbh) + +// Explicitly invoke main when the script is run +main()