diff --git a/docs/api/v4.0-api-baseline.txt b/docs/api/v4.0-api-baseline.txt index c0a5855b..40adc7f1 100644 --- a/docs/api/v4.0-api-baseline.txt +++ b/docs/api/v4.0-api-baseline.txt @@ -4,7 +4,7 @@ # (parameter names dropped) and struct/union/enum layouts in member order. # `make api-check` (CI) fails when the headers no longer match this file. -== functions (766) == +== functions (770) == cwist_accept_socket cwist_alloc cwist_alloc_array @@ -561,6 +561,10 @@ cwist_orm_insert_returning_llong cwist_orm_insert_returning_long cwist_orm_open_socket cwist_orm_query +cwist_orm_query_typed +cwist_orm_result_cell +cwist_orm_result_column_index +cwist_orm_result_free cwist_orm_rollback cwist_orm_select cwist_orm_select_one_double @@ -772,7 +776,7 @@ cwist_zod_parse cwist_zod_print_errors cwist_zod_validate -== function signatures (767) == +== function signatures (771) == cwist_accept_socket: cwist_error_t (int, struct sockaddr *, void (*)(int client_fd, void *ctx), void *) cwist_alloc: void * (size_t) cwist_alloc_array: void * (size_t, size_t) @@ -1330,6 +1334,10 @@ cwist_orm_insert_returning_llong: cwist_error_t (cwist_orm_t *, const char *, co cwist_orm_insert_returning_long: cwist_error_t (cwist_orm_t *, const char *, const cJSON *, const char *, long *) cwist_orm_open_socket: cwist_orm_t * (int) cwist_orm_query: cwist_error_t (cwist_orm_t *, const char *, cJSON **) +cwist_orm_query_typed: cwist_error_t (cwist_orm_t *, const char *, cwist_orm_result_t **) +cwist_orm_result_cell: const cwist_orm_value_t * (const cwist_orm_result_t *, size_t, size_t) +cwist_orm_result_column_index: size_t (const cwist_orm_result_t *, const char *) +cwist_orm_result_free: void (cwist_orm_result_t *) cwist_orm_rollback: cwist_error_t (cwist_orm_t *) cwist_orm_select: cwist_error_t (cwist_orm_t *, const char *, const char *, const char *, cJSON **) cwist_orm_select_one_double: cwist_error_t (cwist_orm_t *, const char *, const char *, const char *, double *) @@ -1565,7 +1573,7 @@ cwist_wasm_component_dispose: void (void *) cwist_wasm_dispatch_memory: const char * (cwist_app *, const char *, size_t, size_t *) cwist_wasm_free: void (void *) -== types (struct/union/enum names) (150) == +== types (struct/union/enum names) (153) == __natsConnection __prim_cwist_error_t bdr_blob_t @@ -1672,6 +1680,9 @@ cwist_nats cwist_nuke_db_t cwist_orm cwist_orm_dialect +cwist_orm_result +cwist_orm_value +cwist_orm_value_type cwist_pb_field cwist_pb_reader cwist_pb_wire_type @@ -1717,7 +1728,7 @@ cwist_zod_result quic_conn_fc quic_stream_fc -== typedefs (201) == +== typedefs (204) == __prim_cwist_error_t bdr_blob_t bdr_entry_t @@ -1853,7 +1864,10 @@ cwist_nats_msg_cb cwist_nats_t cwist_nuke_db_t cwist_orm_dialect_t +cwist_orm_result_t cwist_orm_t +cwist_orm_value_t +cwist_orm_value_type_t cwist_pb_field cwist_pb_reader cwist_pb_wire_type_t @@ -1920,7 +1934,7 @@ quic_conn_fc_t quic_net_quality_t quic_stream_fc_t -== enum constants (224) == +== enum constants (229) == CWIST_ASYNC_CLOSE CWIST_ASYNC_DEFER CWIST_ASYNC_DETACH @@ -2123,6 +2137,11 @@ CWIST_ORM_MARIADB CWIST_ORM_MYSQL CWIST_ORM_POSTGRES CWIST_ORM_SQLITE +CWIST_ORM_TYPE_BLOB +CWIST_ORM_TYPE_INTEGER +CWIST_ORM_TYPE_NULL +CWIST_ORM_TYPE_REAL +CWIST_ORM_TYPE_TEXT CWIST_PB_32BIT CWIST_PB_64BIT CWIST_PB_LEN diff --git a/include/cwist/core/orm/orm.h b/include/cwist/core/orm/orm.h index 16e252eb..0f79f7da 100644 --- a/include/cwist/core/orm/orm.h +++ b/include/cwist/core/orm/orm.h @@ -143,7 +143,10 @@ cwist_error_t cwist_orm_exec(cwist_orm_t *orm, const char *sql); * @brief Execute a SELECT query and return the result set. * * The returned cJSON object is an array. Each element is an object - * whose keys are column names and whose values are the row data. + * whose keys are column names and whose values are the row data. Values + * keep the legacy text-based typing (canonical integer text becomes a + * number); use cwist_orm_query_typed() for values typed by the column's + * SQLite storage class. * * @param orm ORM session. * @param sql Null-terminated SELECT statement. @@ -261,6 +264,116 @@ cwist_error_t cwist_orm_insert_returning_llong(cwist_orm_t *orm, const char *tab long long *: cwist_orm_insert_returning_llong, \ cJSON **: cwist_orm_insert_returning_json)((orm), (table), (data), (returning_col), (out)) +/* ------------------------------------------------------------------ */ +/* Column-typed result sets (#354) */ +/* ------------------------------------------------------------------ */ + +/** + * @brief Storage class of a typed result cell. + * + * The class comes from the SQLite value's storage class + * (sqlite3_column_type()), not from its text: a TEXT column holding + * canonical integer text is CWIST_ORM_TYPE_TEXT, and SQL NULL is + * CWIST_ORM_TYPE_NULL, distinct from an empty TEXT value. + */ +typedef enum cwist_orm_value_type { + CWIST_ORM_TYPE_NULL = 0, /**< SQL NULL */ + CWIST_ORM_TYPE_INTEGER, /**< INTEGER, exact through the full int64 range */ + CWIST_ORM_TYPE_REAL, /**< REAL as a double */ + CWIST_ORM_TYPE_TEXT, /**< TEXT as a null-terminated string */ + CWIST_ORM_TYPE_BLOB, /**< BLOB as (blob, blob_len) bytes */ +} cwist_orm_value_type_t; + +/** + * @brief One cell of a column-typed result set. + * + * Exactly one payload field is meaningful per cwist_orm_value_type_t: + * i64 for CWIST_ORM_TYPE_INTEGER (exact above 2^53), real for + * CWIST_ORM_TYPE_REAL, text for CWIST_ORM_TYPE_TEXT (never NULL), and + * (blob, blob_len) for CWIST_ORM_TYPE_BLOB (blob is NULL when the BLOB + * is empty). Owned by the result set; released by + * cwist_orm_result_free(). + */ +typedef struct cwist_orm_value { + cwist_orm_value_type_t type; + int64_t i64; /**< CWIST_ORM_TYPE_INTEGER */ + double real; /**< CWIST_ORM_TYPE_REAL */ + char *text; /**< CWIST_ORM_TYPE_TEXT */ + unsigned char *blob; /**< CWIST_ORM_TYPE_BLOB (NULL when empty) */ + size_t blob_len; /**< CWIST_ORM_TYPE_BLOB */ +} cwist_orm_value_t; + +/** + * @brief Column-typed result set produced by cwist_orm_query_typed(). + * + * Cells are stored row-major: cell (row, column) lives at + * cells[row * column_count + column]. Column names come from the + * result-producing statement. Everything is released by + * cwist_orm_result_free(). + */ +typedef struct cwist_orm_result { + size_t column_count; + size_t row_count; + char **columns; + cwist_orm_value_t *cells; +} cwist_orm_result_t; + +/** + * @brief Execute a SELECT-style query and return column-typed rows. + * + * Values are typed by each cell's SQLite storage class, not by its + * text, unlike cwist_orm_query() whose JSON values keep the legacy + * text-based typing (canonical integer text becomes a number there). + * Here a TEXT cell stays TEXT even when it holds digits, INTEGER keeps + * every digit above 2^53, and SQL NULL is a distinct type from an + * empty TEXT value. + * + * @param orm ORM session. + * @param sql Null-terminated SELECT statement. + * @param result [out] Receives a heap-allocated cwist_orm_result_t that + * the caller must release with cwist_orm_result_free(). + * On failure the pointer is set to @c NULL. + * @return cwist_error_t with err_i16 == 0 on success; + * CWIST_ERROR_INVALID_PARAM for NULL arguments, + * CWIST_ERROR_IO on transport failure, CWIST_ERROR_PROTOCOL on + * a backend error or a response without typed column metadata. + */ +cwist_error_t cwist_orm_query_typed(cwist_orm_t *orm, const char *sql, + cwist_orm_result_t **result); + +/** + * @brief Release a result set from cwist_orm_query_typed(). + * + * Safe to call with @c NULL. + * + * @param result Result set to free. + */ +void cwist_orm_result_free(cwist_orm_result_t *result); + +/** + * @brief Find a column index by name in a typed result set. + * + * @param result Result set. + * @param column Column name. + * @return The column index, or @p result->column_count when the name is + * not present (or either argument is NULL). + */ +size_t cwist_orm_result_column_index(const cwist_orm_result_t *result, + const char *column); + +/** + * @brief Read one cell of a typed result set. + * + * @param result Result set. + * @param row Row index. + * @param column Column index. + * @return Pointer to the cell, or @c NULL when the coordinates are out + * of range or @p result is NULL. The pointer is owned by the + * result set and stays valid until cwist_orm_result_free(). + */ +const cwist_orm_value_t *cwist_orm_result_cell(const cwist_orm_result_t *result, + size_t row, size_t column); + /* ------------------------------------------------------------------ */ /* _Generic type-dispatched SELECT single scalar */ /* ------------------------------------------------------------------ */ diff --git a/scripts/ci/malloc-baseline.txt b/scripts/ci/malloc-baseline.txt index bf89018e..21580660 100644 --- a/scripts/ci/malloc-baseline.txt +++ b/scripts/ci/malloc-baseline.txt @@ -19,7 +19,9 @@ 1 src/core/mem/arena.c:tls = (cwist_arena_tls_t *)calloc(1, sizeof(cwist_arena_tls_t)); 1 src/core/mem/gc.c:cwist_conn_entry_t *heap = (cwist_conn_entry_t *)calloc(want, sizeof(*heap)); 1 src/core/mem/gc.c:cwist_conn_entry_t *slots = (cwist_conn_entry_t *)calloc(new_cap, sizeof(*slots)); +1 src/core/mem/gc.c:cwist_reg_entry_t *slots = (cwist_reg_entry_t *)calloc(new_cap, sizeof(*slots)); 1 src/core/mem/gc.c:free(g_conn_slots); +1 src/core/mem/gc.c:free(g_reg_slots); 1 src/core/mem/gc.c:free(heap); 2 src/core/mem/gc.c:free(owner); 1 src/core/mem/gc.c:free(pending); @@ -43,6 +45,7 @@ 1 src/core/orm/orm.c:char *values = (char *)malloc(1); 1 src/core/orm/orm.c:cwist_orm_t *orm = (cwist_orm_t *)calloc(1, sizeof(*orm)); 1 src/core/orm/orm.c:default: return strdup(id); +1 src/core/orm/orm.c:for (size_t i = 0; i < result->column_count; i++) free(result->columns[i]); 3 src/core/orm/orm.c:free(base); 7 src/core/orm/orm.c:free(columns); 1 src/core/orm/orm.c:free(commit_payload); @@ -51,26 +54,43 @@ 1 src/core/orm/orm.c:free(final); 7 src/core/orm/orm.c:free(literal); 1 src/core/orm/orm.c:free(orm); -4 src/core/orm/orm.c:free(payload); +2 src/core/orm/orm.c:free(out); +5 src/core/orm/orm.c:free(payload); 3 src/core/orm/orm.c:free(qcol); 7 src/core/orm/orm.c:free(qid); 2 src/core/orm/orm.c:free(qret); 17 src/core/orm/orm.c:free(qtable); 1 src/core/orm/orm.c:free(raw); +1 src/core/orm/orm.c:free(result); +1 src/core/orm/orm.c:free(result->cells); +1 src/core/orm/orm.c:free(result->cells[i].blob); +1 src/core/orm/orm.c:free(result->cells[i].text); +1 src/core/orm/orm.c:free(result->columns); 6 src/core/orm/orm.c:free(set_clause); 8 src/core/orm/orm.c:free(sql); 7 src/core/orm/orm.c:free(values); +1 src/core/orm/orm.c:if (!len) free(bytes); 1 src/core/orm/orm.c:if (!node) return strdup("NULL"); 1 src/core/orm/orm.c:if (!node->valuestring) return strdup("''"); 1 src/core/orm/orm.c:if (!raw) return strdup("NULL"); +1 src/core/orm/orm.c:res = (cwist_orm_result_t *)calloc(1, sizeof(*res)); +1 src/core/orm/orm.c:res->cells = (cwist_orm_value_t *)calloc( +1 src/core/orm/orm.c:res->columns = (char **)calloc(res->column_count ? res->column_count : 1, sizeof(char *)); +1 src/core/orm/orm.c:res->columns[i] = strdup(name->valuestring); 1 src/core/orm/orm.c:return strdup("NULL"); 1 src/core/orm/orm.c:return strdup(buf); 1 src/core/orm/orm.c:return strdup(cJSON_IsTrue(node) ? "1" : "0"); 1 src/core/orm/orm.c:return strdup(cJSON_IsTrue(node) ? "TRUE" : "FALSE"); +1 src/core/orm/orm.c:unsigned char *out = (unsigned char *)malloc(cap ? cap : 1); +1 src/core/orm/orm.c:v->text = strdup(payload->valuestring); 1 src/core/orm/orm_socket.c:(cwist_orm_worker_ctx_t *)calloc(1, sizeof(*ctx)); +1 src/core/orm/orm_socket.c:char *out = (char *)malloc(out_cap); 1 src/core/orm/orm_socket.c:ctx->db_path = strdup(db_path); 2 src/core/orm/orm_socket.c:free(ctx); 1 src/core/orm/orm_socket.c:free(ctx->db_path); +1 src/core/orm/orm_socket.c:free(errmsg); +1 src/core/orm/orm_socket.c:free(out); +1 src/core/orm/orm_socket.c:if (rc != SQLITE_OK && errmsg) *errmsg = strdup(sqlite3_errmsg(db)); 1 src/core/utils/json_heal.c:free(r->json); 1 src/core/utils/json_heal.c:free(recovered); /* SLLM callback uses plain malloc per contract */ 9 src/net/db_sync/db_sync.c:free(blob); @@ -108,10 +128,10 @@ 1 src/net/http/http3.c:unsigned char *pkt_buf = malloc(65535); 1 src/net/http/http3.c:unsigned char *tmp = realloc(st->seq_buf, cap); 1 src/net/http/http3.c:unsigned char(*batch_bufs)[65535] = malloc(sizeof(*batch_bufs) * H3_RECV_BATCH); +1 src/net/http/http3_client.c:char *copy = strdup(host); 1 src/net/http/http3_client.c:char *name = malloc(name_len + 1); 1 src/net/http/http3_client.c:char *tmp = realloc(st->resp_body, new_cap); 1 src/net/http/http3_client.c:char *value = malloc(value_len + 1); -1 src/net/http/http3_client.c:char *copy = strdup(host); 1 src/net/http/http3_client.c:client->in_dgram.data = malloc(len); 1 src/net/http/http3_client.c:client->out_dgram.data = malloc(len); 1 src/net/http/http3_client.c:cwist_http3_client *client = calloc(1, sizeof(*client)); diff --git a/src/core/orm/orm.c b/src/core/orm/orm.c index a34a9b79..926e2fdf 100644 --- a/src/core/orm/orm.c +++ b/src/core/orm/orm.c @@ -1418,3 +1418,276 @@ cwist_error_t cwist_orm_insert_returning_i64(cwist_orm_t *orm, const char *table free(sql); return err; } + +/* ------------------------------------------------------------------ */ +/* Column-typed result sets (#354) */ +/* ------------------------------------------------------------------ */ + +/** + * @brief Decode one base64 character; -1 when it is not in the alphabet. + */ +static int orm_b64_value(unsigned char c) { + if (c >= 'A' && c <= 'Z') return c - 'A'; + if (c >= 'a' && c <= 'z') return c - 'a' + 26; + if (c >= '0' && c <= '9') return c - '0' + 52; + if (c == '+') return 62; + if (c == '/') return 63; + return -1; +} + +/** + * @brief Base64-decode a worker BLOB cell into a new buffer. + * @return Heap buffer (NULL when the decoded length is 0), or NULL with + * *out_len == SIZE_MAX when the input is not valid base64. + */ +static unsigned char *orm_b64_decode(const char *in, size_t *out_len) { + size_t n = strlen(in); + if (n % 4 != 0) { + *out_len = SIZE_MAX; + return NULL; + } + size_t pad = 0; + if (n > 0 && in[n - 1] == '=') pad++; + if (n > 1 && in[n - 2] == '=') pad++; + size_t cap = n / 4 * 3 - pad; + unsigned char *out = (unsigned char *)malloc(cap ? cap : 1); + if (!out) { + *out_len = SIZE_MAX; + return NULL; + } + size_t o = 0; + for (size_t i = 0; i < n; i += 4) { + int v[4]; + int chars = 3; + for (int k = 0; k < 4; k++) { + char c = in[i + k]; + if (c == '=') { + if (i + 4 != n || k < 2 || (k == 2 && in[i + 3] != '=')) { + free(out); + *out_len = SIZE_MAX; + return NULL; + } + v[k] = 0; + chars--; + } else { + v[k] = orm_b64_value((unsigned char)c); + if (v[k] < 0) { + free(out); + *out_len = SIZE_MAX; + return NULL; + } + } + } + unsigned triple = ((unsigned)v[0] << 18) | ((unsigned)v[1] << 12) | + ((unsigned)v[2] << 6) | (unsigned)v[3]; + if (o < cap) out[o++] = (unsigned char)(triple >> 16); + if (o < cap) out[o++] = (unsigned char)(triple >> 8); + if (o < cap) out[o++] = (unsigned char)triple; + } + *out_len = cap; + return out; +} + +/** + * @brief Materialize one typed cell into a cwist_orm_value_t. + * + * INTEGER digits are parsed with strtoll (no double round-trip, so values + * above 2^53 stay exact); anything but canonical integer text that fits + * int64_t is a protocol error. Ownership of text/blob moves to @p v. + * @return CWIST_SUCCESS or CWIST_ERROR_PROTOCOL / CWIST_ERROR_NOMEM. + */ +static int orm_fill_typed_cell(cwist_orm_value_t *v, const cJSON *cell) { + const cJSON *tag = cJSON_GetObjectItemCaseSensitive(cell, "t"); + if (!cJSON_IsString(tag) || !tag->valuestring) return CWIST_ERROR_PROTOCOL; + const char *t = tag->valuestring; + const cJSON *payload = cJSON_GetObjectItemCaseSensitive(cell, "v"); + + if (strcmp(t, "null") == 0) { + v->type = CWIST_ORM_TYPE_NULL; + return CWIST_SUCCESS; + } + if (strcmp(t, "int") == 0) { + if (!cJSON_IsString(payload) || !payload->valuestring) return CWIST_ERROR_PROTOCOL; + const char *s = payload->valuestring; + if (!*s) return CWIST_ERROR_PROTOCOL; + errno = 0; + char *endptr = NULL; + long long val = strtoll(s, &endptr, 10); + if (errno == ERANGE || !endptr || *endptr != '\0') return CWIST_ERROR_PROTOCOL; + v->type = CWIST_ORM_TYPE_INTEGER; + v->i64 = (int64_t)val; + return CWIST_SUCCESS; + } + if (strcmp(t, "real") == 0) { + if (!cJSON_IsNumber(payload)) return CWIST_ERROR_PROTOCOL; + v->type = CWIST_ORM_TYPE_REAL; + v->real = payload->valuedouble; + return CWIST_SUCCESS; + } + if (strcmp(t, "text") == 0) { + if (!cJSON_IsString(payload) || !payload->valuestring) return CWIST_ERROR_PROTOCOL; + v->type = CWIST_ORM_TYPE_TEXT; + v->text = strdup(payload->valuestring); + return v->text ? CWIST_SUCCESS : CWIST_ERROR_NOMEM; + } + if (strcmp(t, "blob") == 0) { + if (!cJSON_IsString(payload) || !payload->valuestring) return CWIST_ERROR_PROTOCOL; + size_t len = 0; + unsigned char *bytes = orm_b64_decode(payload->valuestring, &len); + if (len == SIZE_MAX) return CWIST_ERROR_PROTOCOL; + v->type = CWIST_ORM_TYPE_BLOB; + v->blob = len ? bytes : NULL; + v->blob_len = len; + if (!len) free(bytes); + return CWIST_SUCCESS; + } + return CWIST_ERROR_PROTOCOL; +} + +/** + * @brief Execute a SELECT-style query and return column-typed rows. + * + * Reads the worker's "typed" payload, which types every cell from its + * SQLite storage class: INTEGER as exact int64 digits, REAL as a double, + * TEXT as a string, BLOB as decoded bytes with its length, and NULL as a + * distinct type. On success @p *result receives a heap-allocated result + * set the caller releases with cwist_orm_result_free(). + * + * @param orm Active session. + * @param sql Null-terminated SQL query. + * @param result [out] Receives the result set, or NULL on failure. + * @return CWIST_SUCCESS on success; CWIST_ERROR_INVALID_PARAM for NULL + * arguments, CWIST_ERROR_IO on transport failure, or + * CWIST_ERROR_PROTOCOL on a backend error or a malformed typed + * payload. + */ +cwist_error_t cwist_orm_query_typed(cwist_orm_t *orm, const char *sql, + cwist_orm_result_t **result) { + cwist_error_t err = make_error(CWIST_ERR_INT16); + if (result) *result = NULL; + if (!orm || !sql || !result) { + err.error.err_i16 = CWIST_ERROR_INVALID_PARAM; + return err; + } + + char *payload = NULL; + if (orm_exchange(orm, sql, &payload) != 0) { + err.error.err_i16 = CWIST_ERROR_IO; + return err; + } + + err.error.err_i16 = CWIST_ERROR_PROTOCOL; + cJSON *root = cJSON_Parse(payload); + free(payload); + if (!root) return err; + + cwist_orm_result_t *res = NULL; + + cJSON *status = cJSON_GetObjectItemCaseSensitive(root, "status"); + if (!cJSON_IsNumber(status) || (int)status->valuedouble != 0) goto done; + + cJSON *typed = cJSON_GetObjectItemCaseSensitive(root, "typed"); + cJSON *columns = cJSON_GetObjectItemCaseSensitive(typed, "columns"); + cJSON *trows = cJSON_GetObjectItemCaseSensitive(typed, "rows"); + if (!cJSON_IsArray(columns) || !cJSON_IsArray(trows)) goto done; + + res = (cwist_orm_result_t *)calloc(1, sizeof(*res)); + if (!res) { + err.error.err_i16 = CWIST_ERROR_NOMEM; + goto done; + } + + res->column_count = (size_t)cJSON_GetArraySize(columns); + res->row_count = (size_t)cJSON_GetArraySize(trows); + + res->columns = (char **)calloc(res->column_count ? res->column_count : 1, sizeof(char *)); + if (!res->columns) { + err.error.err_i16 = CWIST_ERROR_NOMEM; + goto fail; + } + for (size_t i = 0; i < res->column_count; i++) { + cJSON *name = cJSON_GetArrayItem(columns, (int)i); + if (!cJSON_IsString(name) || !name->valuestring) goto fail; + res->columns[i] = strdup(name->valuestring); + if (!res->columns[i]) { + err.error.err_i16 = CWIST_ERROR_NOMEM; + goto fail; + } + } + + if (res->row_count > 0) { + if (res->column_count == 0 || + res->row_count > SIZE_MAX / res->column_count / sizeof(cwist_orm_value_t)) + goto fail; + res->cells = (cwist_orm_value_t *)calloc( + res->row_count * res->column_count, sizeof(cwist_orm_value_t)); + if (!res->cells) { + err.error.err_i16 = CWIST_ERROR_NOMEM; + goto fail; + } + } + + size_t idx = 0; + cJSON *trow = NULL; + cJSON_ArrayForEach(trow, trows) { + if (!cJSON_IsArray(trow) || (size_t)cJSON_GetArraySize(trow) != res->column_count) + goto fail; + for (size_t c = 0; c < res->column_count; c++, idx++) { + int rc = orm_fill_typed_cell(&res->cells[idx], cJSON_GetArrayItem(trow, (int)c)); + if (rc != CWIST_SUCCESS) { + err.error.err_i16 = rc; + goto fail; + } + } + } + + *result = res; + err.error.err_i16 = CWIST_SUCCESS; + goto done; + +fail: + cwist_orm_result_free(res); +done: + cJSON_Delete(root); + return err; +} + +/** + * @brief Release a result set from cwist_orm_query_typed(). + * + * Frees the column names, every owned cell payload, and the structure + * itself. Safe to call with NULL. + * + * @param result Result set to free. + */ +void cwist_orm_result_free(cwist_orm_result_t *result) { + if (!result) return; + if (result->cells) { + size_t total = result->row_count * result->column_count; + for (size_t i = 0; i < total; i++) { + free(result->cells[i].text); + free(result->cells[i].blob); + } + } + if (result->columns) { + for (size_t i = 0; i < result->column_count; i++) free(result->columns[i]); + } + free(result->columns); + free(result->cells); + free(result); +} + +size_t cwist_orm_result_column_index(const cwist_orm_result_t *result, const char *column) { + if (!result || !column) return result ? result->column_count : 0; + for (size_t i = 0; i < result->column_count; i++) { + if (result->columns[i] && strcmp(result->columns[i], column) == 0) return i; + } + return result->column_count; +} + +const cwist_orm_value_t *cwist_orm_result_cell(const cwist_orm_result_t *result, size_t row, + size_t column) { + if (!result || !result->cells || row >= result->row_count || column >= result->column_count) + return NULL; + return &result->cells[row * result->column_count + column]; +} diff --git a/src/core/orm/orm_socket.c b/src/core/orm/orm_socket.c index 9c881438..733c43c0 100644 --- a/src/core/orm/orm_socket.c +++ b/src/core/orm/orm_socket.c @@ -109,59 +109,212 @@ static void free_worker_ctx(cwist_orm_worker_ctx_t *ctx) } /* ------------------------------------------------------------------------- */ -/* SQLite callback context */ +/* SQLite statement execution */ /* ------------------------------------------------------------------------- */ /** - * @brief Accumulator used by sqlite3_exec callback to build a JSON array. + * @brief Add one cell of the current row to the legacy JSON row object. + * + * The legacy "rows" payload keeps its text-based typing so existing + * readers are unaffected: canonical integer text is emitted as raw JSON + * digits, other numeric-looking text stays a string, and NULL stays null. + */ +static void socket_add_legacy_cell(cJSON *row, sqlite3_stmt *stmt, int i) { + const char *txt = (const char *)sqlite3_column_text(stmt, i); + const char *colname = sqlite3_column_name(stmt, i); + if (!colname) colname = ""; + + if (!txt) { + cJSON_AddNullToObject(row, colname); + return; + } + + /* Attempt numeric conversion for cleaner JSON. */ + char *endptr = NULL; + errno = 0; + long long ll = strtoll(txt, &endptr, 10); + char canon[32]; + if (endptr && *endptr == '\0' && errno == 0) { + /* Emit the digits as raw JSON: cJSON prints numbers with 15 + * significant digits, which corrupted integers from ~1e15 up. + * Only canonical integer text becomes a number; "007" or + * " 12" is text that merely parses as one and stays a string. */ + snprintf(canon, sizeof(canon), "%lld", ll); + if (strcmp(canon, txt) == 0) { + cJSON_AddRawToObject(row, colname, txt); + } else { + cJSON_AddStringToObject(row, colname, txt); + } + } else { + double d = strtod(txt, &endptr); + if (endptr && *endptr == '\0' && errno == 0) { + cJSON_AddNumberToObject(row, colname, d); + } else { + cJSON_AddStringToObject(row, colname, txt); + } + } +} + +/** @brief Base64 alphabet for BLOB cells in the typed payload. */ +static const char socket_b64_table[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +/** + * @brief Base64-encode @p n bytes of @p data into a new cJSON string. + * @return The string node, or NULL on allocation failure. + */ +static cJSON *socket_b64_string(const unsigned char *data, int n) { + size_t out_cap = ((size_t)n / 3 + 1) * 4 + 1; + char *out = (char *)malloc(out_cap); + if (!out) return NULL; + size_t o = 0; + int i = 0; + for (; i + 3 <= n; i += 3) { + unsigned v = ((unsigned)data[i] << 16) | ((unsigned)data[i + 1] << 8) | data[i + 2]; + out[o++] = socket_b64_table[(v >> 18) & 63]; + out[o++] = socket_b64_table[(v >> 12) & 63]; + out[o++] = socket_b64_table[(v >> 6) & 63]; + out[o++] = socket_b64_table[v & 63]; + } + int rem = n - i; + if (rem == 1) { + unsigned v = (unsigned)data[i] << 16; + out[o++] = socket_b64_table[(v >> 18) & 63]; + out[o++] = socket_b64_table[(v >> 12) & 63]; + out[o++] = '='; + out[o++] = '='; + } else if (rem == 2) { + unsigned v = ((unsigned)data[i] << 16) | ((unsigned)data[i + 1] << 8); + out[o++] = socket_b64_table[(v >> 18) & 63]; + out[o++] = socket_b64_table[(v >> 12) & 63]; + out[o++] = socket_b64_table[(v >> 6) & 63]; + out[o++] = '='; + } + out[o] = '\0'; + cJSON *node = cJSON_CreateString(out); + free(out); + return node; +} + +/** + * @brief Build the typed cell object for one column of the current row. + * + * The cell is typed from the value's SQLite storage class + * (sqlite3_column_type()), not its text: {"t":"int","v":"-5"} carries + * the digits as a string so int64 values above 2^53 survive the JSON + * payload exactly; "real" is a JSON number; "text" a JSON string; + * "blob" a base64 string with its byte length in "n"; "null" has no + * payload. A TEXT value that only looks numeric keeps t=="text". + */ +static cJSON *socket_typed_cell(sqlite3_stmt *stmt, int i, int ctype) { + cJSON *cell = cJSON_CreateObject(); + if (!cell) return NULL; + + /* Read the typed payload before any text conversion: sqlite3_column_text() + * changes what sqlite3_column_type() reports for the same column. */ + switch (ctype) { + case SQLITE_INTEGER: { + char digits[32]; + snprintf(digits, sizeof(digits), "%lld", (long long)sqlite3_column_int64(stmt, i)); + cJSON_AddStringToObject(cell, "t", "int"); + cJSON_AddStringToObject(cell, "v", digits); + break; + } + case SQLITE_FLOAT: + cJSON_AddStringToObject(cell, "t", "real"); + cJSON_AddNumberToObject(cell, "v", sqlite3_column_double(stmt, i)); + break; + case SQLITE_BLOB: { + const unsigned char *blob = (const unsigned char *)sqlite3_column_blob(stmt, i); + int n = sqlite3_column_bytes(stmt, i); + cJSON_AddStringToObject(cell, "t", "blob"); + cJSON_AddItemToObject(cell, "v", socket_b64_string(blob ? blob : (const unsigned char *)"", n)); + cJSON_AddNumberToObject(cell, "n", (double)n); + break; + } + case SQLITE_TEXT: + cJSON_AddStringToObject(cell, "t", "text"); + cJSON_AddStringToObject(cell, "v", (const char *)sqlite3_column_text(stmt, i)); + break; + case SQLITE_NULL: + default: + cJSON_AddStringToObject(cell, "t", "null"); + break; + } + return cell; +} + +/** + * @brief Accumulator used by the statement loop to build both payloads. */ typedef struct { - cJSON *rows; /**< cJSON array receiving each row object. */ + cJSON *rows; /**< Legacy cJSON array of row objects (text-typed). */ + cJSON *columns; /**< Typed payload: column names of the row-producing statement. */ + cJSON *trows; /**< Typed payload: array of row arrays, parallel to @p rows. */ } query_accumulator_t; /** - * @brief sqlite3_exec callback that packs rows into a cJSON array. + * @brief Run every statement in @p sql, collecting rows into @p acc. + * + * Iterates the text statement by statement (sqlite3_prepare_v2's tail + * pointer), so multi-statement requests such as the MySQL-dialect + * "INSERT ...; SELECT LAST_INSERT_ID() ..." behave like sqlite3_exec + * did: rows from all statements merge into one array. Column names for + * the typed payload come from the last statement that produces columns. + * + * @return SQLITE_OK on success, otherwise the failing statement's code + * with @p errmsg set to a heap copy of sqlite3_errmsg(). */ -static int socket_query_callback(void *data, int argc, char **argv, - char **azColName) -{ - query_accumulator_t *acc = (query_accumulator_t *)data; - cJSON *row = cJSON_CreateObject(); - if (!row) return 1; /* abort */ - - for (int i = 0; i < argc; i++) { - if (argv[i]) { - /* Attempt numeric conversion for cleaner JSON. */ - char *endptr = NULL; - errno = 0; - long long ll = strtoll(argv[i], &endptr, 10); - char canon[32]; - if (endptr && *endptr == '\0' && errno == 0) { - /* Emit the digits as raw JSON: cJSON prints numbers with 15 - * significant digits, which corrupted integers from ~1e15 up. - * Only canonical integer text becomes a number; "007" or - * " 12" is text that merely parses as one and stays a string. */ - snprintf(canon, sizeof(canon), "%lld", ll); - if (strcmp(canon, argv[i]) == 0) { - cJSON_AddRawToObject(row, azColName[i], argv[i]); - } else { - cJSON_AddStringToObject(row, azColName[i], argv[i]); - } - } else { - double d = strtod(argv[i], &endptr); - if (endptr && *endptr == '\0' && errno == 0) { - cJSON_AddNumberToObject(row, azColName[i], d); - } else { - cJSON_AddStringToObject(row, azColName[i], argv[i]); +static int socket_exec_all(sqlite3 *db, const char *sql, query_accumulator_t *acc, + char **errmsg) { + const char *tail = sql; + int rc = SQLITE_OK; + + while (tail && *tail) { + sqlite3_stmt *stmt = NULL; + rc = sqlite3_prepare_v2(db, tail, -1, &stmt, &tail); + if (rc != SQLITE_OK) break; + if (!stmt) continue; /* comment or whitespace between statements */ + + int ncols = sqlite3_column_count(stmt); + if (ncols > 0 && acc->columns) { + /* Re-shape the typed column list for this statement. */ + cJSON_Delete(acc->columns); + acc->columns = cJSON_CreateArray(); + if (acc->columns) { + for (int i = 0; i < ncols; i++) { + const char *name = sqlite3_column_name(stmt, i); + cJSON_AddItemToArray(acc->columns, cJSON_CreateString(name ? name : "")); } } - } else { - cJSON_AddNullToObject(row, azColName[i]); } + + while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { + cJSON *row = cJSON_CreateObject(); + cJSON *trow = cJSON_CreateArray(); + if (!row || !trow) { + cJSON_Delete(row); + cJSON_Delete(trow); + rc = SQLITE_NOMEM; + break; + } + for (int i = 0; i < ncols; i++) { + int ctype = sqlite3_column_type(stmt, i); + cJSON_AddItemToArray(trow, socket_typed_cell(stmt, i, ctype)); + socket_add_legacy_cell(row, stmt, i); + } + cJSON_AddItemToArray(acc->rows, row); + cJSON_AddItemToArray(acc->trows, trow); + } + + int frc = sqlite3_finalize(stmt); + if (rc == SQLITE_DONE && frc != SQLITE_OK) rc = frc; + if (rc != SQLITE_DONE) break; + rc = SQLITE_OK; } - cJSON_AddItemToArray(acc->rows, row); - return 0; + if (rc != SQLITE_OK && errmsg) *errmsg = strdup(sqlite3_errmsg(db)); + return rc; } /* ------------------------------------------------------------------------- */ @@ -214,15 +367,23 @@ static void *cwist_orm_socket_worker(void *arg) * see the trust boundary note at the top of this file. */ query_accumulator_t acc; acc.rows = cJSON_CreateArray(); + acc.columns = cJSON_CreateArray(); + acc.trows = cJSON_CreateArray(); char *errmsg = NULL; - rc = sqlite3_exec(db, sql, socket_query_callback, &acc, &errmsg); + if (!acc.rows || !acc.columns || !acc.trows) { + rc = SQLITE_NOMEM; + } else { + rc = socket_exec_all(db, sql, &acc, &errmsg); + } cwist_free(sql); /* ---- build response payload ---- */ cJSON *resp = cJSON_CreateObject(); if (!resp) { cJSON_Delete(acc.rows); + cJSON_Delete(acc.columns); + cJSON_Delete(acc.trows); if (errmsg) sqlite3_free(errmsg); break; } @@ -230,11 +391,19 @@ static void *cwist_orm_socket_worker(void *arg) cJSON_AddNumberToObject(resp, "status", (double)rc); if (errmsg) { cJSON_AddStringToObject(resp, "error", errmsg); - sqlite3_free(errmsg); + free(errmsg); } else { cJSON_AddNullToObject(resp, "error"); } cJSON_AddItemToObject(resp, "rows", acc.rows); + cJSON *typed = cJSON_AddObjectToObject(resp, "typed"); + if (typed) { + cJSON_AddItemToObject(typed, "columns", acc.columns); + cJSON_AddItemToObject(typed, "rows", acc.trows); + } else { + cJSON_Delete(acc.columns); + cJSON_Delete(acc.trows); + } char *payload = cJSON_PrintUnformatted(resp); cJSON_Delete(resp); diff --git a/tests/test_orm.c b/tests/test_orm.c index bf5beb6f..8bcc5d10 100644 --- a/tests/test_orm.c +++ b/tests/test_orm.c @@ -282,12 +282,102 @@ static void test_dialects(void) { cwist_orm_close_socket(orm); } +/* #354 ask 2: values typed by the column's SQLite storage class, not by + * their text. TEXT holding digits stays TEXT, INTEGER stays exact above + * 2^53, BLOB comes back as bytes with a length, and NULL is distinct from + * an empty string. */ +static void test_query_typed(void) { + cwist_orm_t *orm = open_memdb(); + cwist_orm_immediate_commit(true); + assert(rc(cwist_orm_exec(orm, + "CREATE TABLE q(id INTEGER PRIMARY KEY, r REAL, s TEXT, b BLOB, n TEXT);")) == 0); + assert(rc(cwist_orm_exec(orm, "INSERT INTO q VALUES " + "(1, 2.5, 'hello', x'00ff10', NULL), " + "(9007199254740993, -0.25, '007', x'', '');")) == 0); + + cwist_orm_result_t *res = NULL; + assert(rc(cwist_orm_query_typed(orm, "SELECT id, r, s, b, n FROM q ORDER BY r DESC;", &res)) == 0); + assert(res && res->row_count == 2 && res->column_count == 5); + assert(cwist_orm_result_column_index(res, "id") == 0); + assert(cwist_orm_result_column_index(res, "n") == 4); + assert(cwist_orm_result_column_index(res, "missing") == res->column_count); + assert(cwist_orm_result_column_index(NULL, "id") == 0); + assert(cwist_orm_result_cell(NULL, 0, 0) == NULL); + assert(cwist_orm_result_cell(res, 2, 0) == NULL); + assert(cwist_orm_result_cell(res, 0, 5) == NULL); + + /* Mixed-type first row: one of every storage class. */ + const cwist_orm_value_t *cell = cwist_orm_result_cell(res, 0, 0); + assert(cell && cell->type == CWIST_ORM_TYPE_INTEGER && cell->i64 == 1); + cell = cwist_orm_result_cell(res, 0, 1); + assert(cell && cell->type == CWIST_ORM_TYPE_REAL && cell->real == 2.5); + cell = cwist_orm_result_cell(res, 0, 2); + assert(cell && cell->type == CWIST_ORM_TYPE_TEXT && strcmp(cell->text, "hello") == 0); + cell = cwist_orm_result_cell(res, 0, 3); + assert(cell && cell->type == CWIST_ORM_TYPE_BLOB && cell->blob_len == 3); + assert(cell->blob[0] == 0x00 && cell->blob[1] == 0xff && cell->blob[2] == 0x10); + cell = cwist_orm_result_cell(res, 0, 4); + assert(cell && cell->type == CWIST_ORM_TYPE_NULL && cell->text == NULL && cell->blob == NULL); + + /* Second row: integer above 2^53 stays exact, canonical-integer TEXT + * stays TEXT, empty BLOB has length 0, empty TEXT is not NULL. */ + cell = cwist_orm_result_cell(res, 1, 0); + assert(cell && cell->type == CWIST_ORM_TYPE_INTEGER && cell->i64 == INT64_C(9007199254740993)); + cell = cwist_orm_result_cell(res, 1, 1); + assert(cell && cell->type == CWIST_ORM_TYPE_REAL && cell->real == -0.25); + cell = cwist_orm_result_cell(res, 1, 2); + assert(cell && cell->type == CWIST_ORM_TYPE_TEXT && strcmp(cell->text, "007") == 0); + cell = cwist_orm_result_cell(res, 1, 3); + assert(cell && cell->type == CWIST_ORM_TYPE_BLOB && cell->blob_len == 0 && cell->blob == NULL); + cell = cwist_orm_result_cell(res, 1, 4); + assert(cell && cell->type == CWIST_ORM_TYPE_TEXT && cell->text[0] == '\0'); + cwist_orm_result_free(res); + res = NULL; + + /* Zero rows still reports the columns. */ + assert(rc(cwist_orm_query_typed(orm, "SELECT id, s FROM q WHERE id = 99;", &res)) == 0); + assert(res && res->row_count == 0 && res->column_count == 2); + assert(cwist_orm_result_cell(res, 0, 0) == NULL); + cwist_orm_result_free(res); + res = NULL; + + /* Errors reset the result. */ + assert(rc(cwist_orm_query_typed(orm, "SELEKT nonsense;", &res)) != 0 && res == NULL); + assert(rc(cwist_orm_query_typed(NULL, "SELECT 1;", &res)) == CWIST_ERROR_INVALID_PARAM); + assert(rc(cwist_orm_query_typed(orm, NULL, &res)) == CWIST_ERROR_INVALID_PARAM); + assert(rc(cwist_orm_query_typed(orm, "SELECT 1;", NULL)) == CWIST_ERROR_INVALID_PARAM); + + /* BLOB bytes that are not valid UTF-8 and do not survive as text. */ + assert(rc(cwist_orm_exec(orm, "CREATE TABLE raw(b BLOB);")) == 0); + assert(rc(cwist_orm_exec(orm, "INSERT INTO raw VALUES (x'c0ffee42');")) == 0); + assert(rc(cwist_orm_query_typed(orm, "SELECT b FROM raw;", &res)) == 0); + cell = cwist_orm_result_cell(res, 0, 0); + assert(cell && cell->type == CWIST_ORM_TYPE_BLOB && cell->blob_len == 4); + assert(cell->blob[0] == 0xc0 && cell->blob[1] == 0xff && cell->blob[2] == 0xee && + cell->blob[3] == 0x42); + cwist_orm_result_free(res); + cwist_orm_result_free(NULL); + + /* The legacy path still types by text: the id is a JSON number rounded + * to a double, and '007' is a string (not canonical integer text). */ + cJSON *rows = NULL; + assert(rc(cwist_orm_query(orm, "SELECT id, s FROM q WHERE id = 9007199254740993;", &rows)) == 0); + cJSON *legacy_row = cJSON_GetArrayItem(rows, 0); + cJSON *legacy_id = cJSON_GetObjectItem(legacy_row, "id"); + assert(cJSON_IsNumber(legacy_id) && legacy_id->valuedouble == 9007199254740992.0); + assert(cJSON_IsString(cJSON_GetObjectItem(legacy_row, "s"))); + cJSON_Delete(rows); + + cwist_orm_close_socket(orm); +} + int main(void) { test_query_and_transactions(); test_insert_returning(); test_select_one(); test_value_fidelity(); test_exact_i64(); + test_query_typed(); test_dialects(); cwist_orm_immediate_commit(false); printf("test_orm: ok\n");