diff --git a/Cargo.lock b/Cargo.lock index 3922f48..29f0ffc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4070,7 +4070,7 @@ dependencies = [ [[package]] name = "sqlite-web" -version = "0.0.1-alpha.0" +version = "0.0.1-alpha.1" dependencies = [ "base64 0.21.7", "js-sys", @@ -4086,7 +4086,7 @@ dependencies = [ [[package]] name = "sqlite-web-core" -version = "0.0.1-alpha.0" +version = "0.0.1-alpha.1" dependencies = [ "alloy", "console_error_panic_hook", diff --git a/packages/sqlite-web-core/src/database_functions/float_negate.rs b/packages/sqlite-web-core/src/database_functions/float_negate.rs new file mode 100644 index 0000000..e79a223 --- /dev/null +++ b/packages/sqlite-web-core/src/database_functions/float_negate.rs @@ -0,0 +1,137 @@ +use super::*; + +// Helper to negate a Rain Float hex string while keeping full precision by +// operating on the binary representation directly. +fn float_negate_hex_to_hex(input_hex: &str) -> Result { + let trimmed = input_hex.trim(); + + if trimmed.is_empty() { + return Err("Empty string is not a valid hex number".to_string()); + } + + // Parse the input hex into a Float + let float_val = + Float::from_hex(trimmed).map_err(|e| format!("Failed to parse Float hex: {e}"))?; + + // Negate the float directly to avoid any formatting or precision loss. + let neg_float = (-float_val).map_err(|e| format!("Failed to negate Float value: {e}"))?; + + // Return as hex string + Ok(neg_float.as_hex()) +} + +// SQLite scalar function wrapper: FLOAT_NEGATE(hex_text) +pub unsafe extern "C" fn float_negate( + context: *mut sqlite3_context, + argc: c_int, + argv: *mut *mut sqlite3_value, +) { + if argc != 1 { + sqlite3_result_error( + context, + c"FLOAT_NEGATE() requires exactly 1 argument".as_ptr(), + -1, + ); + return; + } + + // Return early for NULL inputs using the documented type check. + if sqlite3_value_type(*argv) == SQLITE_NULL { + sqlite3_result_null(context); + return; + } + + // Get the text value (now known to be non-NULL). + let value_ptr = sqlite3_value_text(*argv); + + let value_cstr = CStr::from_ptr(value_ptr as *const c_char); + let value_str = match value_cstr.to_str() { + Ok(value_str) => value_str, + Err(_) => { + sqlite3_result_error(context, c"invalid UTF-8".as_ptr(), -1); + return; + } + }; + + match float_negate_hex_to_hex(value_str) { + Ok(result_hex) => { + if let Ok(result_cstr) = CString::new(result_hex) { + sqlite3_result_text( + context, + result_cstr.as_ptr(), + result_cstr.as_bytes().len() as c_int, + SQLITE_TRANSIENT(), + ); + } else { + sqlite3_result_error(context, c"Failed to create result string".as_ptr(), -1); + } + } + Err(e) => match CString::new(e) { + Ok(error_msg) => { + sqlite3_result_error(context, error_msg.as_ptr(), -1); + } + Err(_) => { + sqlite3_result_error( + context, + c"Error message contained interior NUL".as_ptr(), + -1, + ); + } + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wasm_bindgen_test::*; + + #[wasm_bindgen_test] + fn test_float_negate_hex_to_hex_pos_to_neg() { + let pos_hex = Float::parse("1.5".to_string()).unwrap().as_hex(); + let expected_neg_hex = Float::parse("-1.5".to_string()).unwrap().as_hex(); + let out = float_negate_hex_to_hex(&pos_hex).unwrap(); + assert_eq!(out, expected_neg_hex); + } + + #[wasm_bindgen_test] + fn test_float_negate_hex_to_hex_neg_to_pos() { + let neg_hex = Float::parse("-2.25".to_string()).unwrap().as_hex(); + let expected_pos_hex = Float::parse("2.25".to_string()).unwrap().as_hex(); + let out = float_negate_hex_to_hex(&neg_hex).unwrap(); + assert_eq!(out, expected_pos_hex); + } + + #[wasm_bindgen_test] + fn test_float_negate_hex_to_hex_zero() { + let zero_hex = Float::parse("0".to_string()).unwrap().as_hex(); + let expected_zero_hex = Float::parse("0".to_string()).unwrap().as_hex(); + let out = float_negate_hex_to_hex(&zero_hex).unwrap(); + assert_eq!(out, expected_zero_hex); + } + + #[wasm_bindgen_test] + fn test_float_negate_hex_to_hex_high_precision() { + let input = "300.123456789012345678"; + let in_hex = Float::parse(input.to_string()).unwrap().as_hex(); + let expected_hex = Float::parse(format!("-{input}")).unwrap().as_hex(); + let out = float_negate_hex_to_hex(&in_hex).unwrap(); + assert_eq!(out, expected_hex); + } + + #[wasm_bindgen_test] + fn test_float_negate_hex_to_hex_whitespace() { + let in_hex = Float::parse("10".to_string()).unwrap().as_hex(); + let wrapped = format!(" {in_hex} "); + let expected_hex = Float::parse("-10".to_string()).unwrap().as_hex(); + let out = float_negate_hex_to_hex(&wrapped).unwrap(); + assert_eq!(out, expected_hex); + } + + #[wasm_bindgen_test] + fn test_float_negate_hex_to_hex_invalid() { + assert!(float_negate_hex_to_hex("0XBADHEX").is_err()); + assert!(float_negate_hex_to_hex("").is_err()); + assert!(float_negate_hex_to_hex("not_hex").is_err()); + } +} diff --git a/packages/sqlite-web-core/src/database_functions/float_sum.rs b/packages/sqlite-web-core/src/database_functions/float_sum.rs index 0d22393..38f44b8 100644 --- a/packages/sqlite-web-core/src/database_functions/float_sum.rs +++ b/packages/sqlite-web-core/src/database_functions/float_sum.rs @@ -186,16 +186,18 @@ mod tests { fn test_float_sum_context_add_hex_without_prefix() { let mut context = FloatSumContext::new(); - assert!(context - .add_value("ffffffff0000000000000000000000000000000000000000000000000000000f") - .is_ok()); // 1.5 + let one_point_five = Float::parse("1.5".to_string()).unwrap().as_hex(); + let one_point_five_no_prefix = one_point_five.trim_start_matches("0x").to_string(); + + assert!(context.add_value(&one_point_five_no_prefix).is_ok()); // 1.5 let result_hex = context.get_total_as_hex().unwrap(); let result_decimal = Float::from_hex(&result_hex).unwrap().format().unwrap(); assert_eq!(result_decimal, "1.5"); - assert!(context - .add_value("fffffffe000000000000000000000000000000000000000000000000000000e1") - .is_ok()); // 2.25 + let two_point_two_five = Float::parse("2.25".to_string()).unwrap().as_hex(); + let two_point_two_five_no_prefix = two_point_two_five.trim_start_matches("0x").to_string(); + + assert!(context.add_value(&two_point_two_five_no_prefix).is_ok()); // 2.25 let result_hex = context.get_total_as_hex().unwrap(); let result_decimal = Float::from_hex(&result_hex).unwrap().format().unwrap(); assert_eq!(result_decimal, "3.75"); // 1.5 + 2.25 = 3.75 @@ -205,12 +207,17 @@ mod tests { fn test_float_sum_context_add_uppercase_hex() { let mut context = FloatSumContext::new(); - assert!(context - .add_value("0XFFFFFFFB0000000000000000000000000000000000000000000000000004CB2F") - .is_err()); // Should fail - uppercase 0X not supported - assert!(context - .add_value("0XFFFFFFFF00000000000000000000000000000000000000000000000000000069") - .is_err()); // Should fail - uppercase 0X not supported + let upper_case_bad = Float::parse("-12345.6789".to_string()) + .unwrap() + .as_hex() + .replacen("0x", "0X", 1); + assert!(context.add_value(&upper_case_bad).is_err()); // Should fail - uppercase 0X not supported + + let another_upper = Float::parse("1024.125".to_string()) + .unwrap() + .as_hex() + .replacen("0x", "0X", 1); + assert!(context.add_value(&another_upper).is_err()); // Should fail - uppercase 0X not supported } #[wasm_bindgen_test] diff --git a/packages/sqlite-web-core/src/database_functions/mod.rs b/packages/sqlite-web-core/src/database_functions/mod.rs index 2c77553..200b8af 100644 --- a/packages/sqlite-web-core/src/database_functions/mod.rs +++ b/packages/sqlite-web-core/src/database_functions/mod.rs @@ -8,10 +8,12 @@ use std::str::FromStr; // Import the individual function modules mod bigint_sum; +mod float_negate; mod float_sum; mod rain_math; use bigint_sum::*; +use float_negate::*; use float_sum::*; pub use rain_math::*; @@ -78,6 +80,26 @@ pub fn register_custom_functions(db: *mut sqlite3) -> Result<(), String> { return Err("Failed to register FLOAT_SUM function".to_string()); } + // Register FLOAT_NEGATE scalar function + let float_negate_name = CString::new("FLOAT_NEGATE").unwrap(); + let ret = unsafe { + sqlite3_create_function_v2( + db, + float_negate_name.as_ptr(), + 1, // 1 argument + SQLITE_UTF8 | SQLITE_DETERMINISTIC | SQLITE_INNOCUOUS, + std::ptr::null_mut(), + Some(float_negate), // xFunc for scalar + None, // No xStep + None, // No xFinal + None, // No destructor + ) + }; + + if ret != SQLITE_OK { + return Err("Failed to register FLOAT_NEGATE function".to_string()); + } + Ok(()) } @@ -86,8 +108,6 @@ mod tests { use super::*; use wasm_bindgen_test::*; - wasm_bindgen_test_configure!(run_in_browser); - #[wasm_bindgen_test] fn test_cstring_conversion() { let test_string = "test string with spaces and symbols!@#$%"; diff --git a/svelte-test/package-lock.json b/svelte-test/package-lock.json index 5042ca4..d9012b0 100644 --- a/svelte-test/package-lock.json +++ b/svelte-test/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.1", "dependencies": { "@rainlanguage/float": "^0.0.0-alpha.22", - "@rainlanguage/sqlite-web": "file:../pkg/rainlanguage-sqlite-web-0.0.1-alpha.0.tgz" + "@rainlanguage/sqlite-web": "file:../pkg/rainlanguage-sqlite-web-0.0.1-alpha.1.tgz" }, "devDependencies": { "@sveltejs/adapter-auto": "^6.0.0", @@ -942,9 +942,9 @@ } }, "node_modules/@rainlanguage/sqlite-web": { - "version": "0.0.1-alpha.0", - "resolved": "file:../pkg/rainlanguage-sqlite-web-0.0.1-alpha.0.tgz", - "integrity": "sha512-9RH9HLJlkxAcEIzRvGJnkWBtogzQUhH3ELmm4509ETgxScGW6ffY5N7TpYJwci6Ealeo9443kxPOK+F4haLc9w==" + "version": "0.0.1-alpha.1", + "resolved": "file:../pkg/rainlanguage-sqlite-web-0.0.1-alpha.1.tgz", + "integrity": "sha512-ESo2QZ19Yb/ieJ3b4uL4Ber7QXG2PXww/qkDkwnxEktpPF1y+9ei7MJ7ch1oN77VXVuD+xg9lTdrzbkqwFr/oQ==" }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.50.2", diff --git a/svelte-test/package.json b/svelte-test/package.json index 7c09389..196d627 100644 --- a/svelte-test/package.json +++ b/svelte-test/package.json @@ -41,6 +41,6 @@ "type": "module", "dependencies": { "@rainlanguage/float": "^0.0.0-alpha.22", - "@rainlanguage/sqlite-web": "file:../pkg/rainlanguage-sqlite-web-0.0.1-alpha.0.tgz" + "@rainlanguage/sqlite-web": "file:../pkg/rainlanguage-sqlite-web-0.0.1-alpha.1.tgz" } } diff --git a/svelte-test/tests/database-functions/float-negate.test.ts b/svelte-test/tests/database-functions/float-negate.test.ts new file mode 100644 index 0000000..d3b0d1f --- /dev/null +++ b/svelte-test/tests/database-functions/float-negate.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + createTestDatabase, + cleanupDatabase, +} from "../fixtures/test-helpers.js"; +import type { SQLiteWasmDatabase } from "@rainlanguage/sqlite-web"; +import { + createFloatHexMap, + decodeFloatHex, + toMixedCase, + withoutPrefix, +} from "../fixtures/float-utils"; + +const floatHex = createFloatHexMap({ + zero: "0", + smallPositive: "0.000000000000000123", + onePointFive: "1.5", + twoPointTwoFive: "2.25", + negativeTwoPointTwoFive: "-2.25", + highPrecision: "300.123456789012345678", +} as const); + +describe("FLOAT_NEGATE Database Function", () => { + let db: SQLiteWasmDatabase; + + beforeEach(async () => { + db = await createTestDatabase(); + }); + + afterEach(async () => { + await cleanupDatabase(db); + }); + + describe("Function Availability", () => { + it("should have FLOAT_NEGATE function available", async () => { + try { + const pragmaResult = await db.query("PRAGMA function_list"); + const functions = JSON.parse(pragmaResult.value || "[]"); + const entry = functions.find((f: any) => f.name === "FLOAT_NEGATE"); + expect(entry).toBeDefined(); + } catch (e) {} + + const sampleHex = floatHex.onePointFive; + const neg = await db.query(`SELECT FLOAT_NEGATE('${sampleHex}') as neg`); + expect(neg.error).toBeFalsy(); + expect(neg).toBeDefined(); + expect(neg.value).toBeDefined(); + const row = JSON.parse(neg.value || "[]")[0]; + expect(typeof row.neg).toBe("string"); + expect(row.neg.startsWith("0x")).toBe(true); + expect(row.neg.length).toBe(66); + }); + }); + + describe("Basic FLOAT_NEGATE Functionality", () => { + const samples: string[] = [ + floatHex.smallPositive, + floatHex.onePointFive, + withoutPrefix(floatHex.twoPointTwoFive), + floatHex.highPrecision, + floatHex.zero, + ]; + + it("should produce negation that sums to zero with original", async () => { + for (const hex of samples) { + const result = await db.query(` + SELECT FLOAT_SUM(amount) as total FROM ( + SELECT '${hex}' as amount + UNION ALL + SELECT FLOAT_NEGATE('${hex}') as amount + ) + `); + expect(result.error).toBeFalsy(); + expect(result.value).toBeDefined(); + const data = JSON.parse(result.value || "[]"); + const total = data[0].total as string; + const decimalTotal = total === "0" ? total : decodeFloatHex(total); + expect(decimalTotal).toBe("0"); + } + }); + + it("should handle whitespace around input", async () => { + const hex = floatHex.twoPointTwoFive; + const wrapped = ` ${hex} `; + const result = await db.query(` + SELECT FLOAT_SUM(amount) as total FROM ( + SELECT '${wrapped}' as amount + UNION ALL + SELECT FLOAT_NEGATE('${wrapped}') as amount + ) + `); + expect(result.error).toBeFalsy(); + expect(result.value).toBeDefined(); + const data = JSON.parse(result.value || "[]"); + const total = data[0].total as string; + const decimalTotal = total === "0" ? total : decodeFloatHex(total); + expect(decimalTotal).toBe("0"); + }); + + it("should accept mixed-case 0x prefix and characters", async () => { + const mixed = toMixedCase(floatHex.highPrecision); + const result = await db.query(` + SELECT FLOAT_SUM(amount) as total FROM ( + SELECT '${mixed}' as amount + UNION ALL + SELECT FLOAT_NEGATE('${mixed}') as amount + ) + `); + expect(result.error).toBeFalsy(); + expect(result.value).toBeDefined(); + const data = JSON.parse(result.value || "[]"); + const total = data[0].total as string; + const decimalTotal = total === "0" ? total : decodeFloatHex(total); + expect(decimalTotal).toBe("0"); + }); + + it("should return original value after double negation", async () => { + const cases = [ + floatHex.onePointFive, + floatHex.negativeTwoPointTwoFive, + floatHex.zero, + floatHex.smallPositive, + ]; + + for (const original of cases) { + const result = await db.query(` + SELECT FLOAT_NEGATE(FLOAT_NEGATE('${original}')) as double_neg + `); + expect(result.error).toBeFalsy(); + expect(result.value).toBeDefined(); + const data = JSON.parse(result.value || "[]"); + const doubleNeg = data[0].double_neg as string; + expect(doubleNeg).toBe(original); + } + }); + }); + + describe("NULL and Error Handling", () => { + it("should return NULL when input is NULL", async () => { + const res = await db.query("SELECT FLOAT_NEGATE(NULL) as neg"); + const data = JSON.parse(res.value || "[]"); + expect(data[0].neg).toBeNull(); + }); + + it("should reject uppercase 0X prefix", async () => { + const bad = floatHex.twoPointTwoFive.replace("0x", "0X"); + const result = await db.query(`SELECT FLOAT_NEGATE('${bad}') as neg`); + expect(result.error).toBeDefined(); + expect(result.error?.msg).toContain("Failed to parse Float hex"); + }); + + it("should reject invalid hex strings", async () => { + const bad = "not_hex"; + const result = await db.query(`SELECT FLOAT_NEGATE('${bad}') as neg`); + expect(result.error).toBeDefined(); + expect(result.error?.msg).toContain("Failed to parse Float hex"); + }); + + it("should reject empty string", async () => { + const bad = ""; + const result = await db.query(`SELECT FLOAT_NEGATE('${bad}') as neg`); + expect(result.error).toBeDefined(); + expect(result.error?.msg).toContain( + "Empty string is not a valid hex number", + ); + }); + }); +}); diff --git a/svelte-test/tests/database-functions/float-sum.test.ts b/svelte-test/tests/database-functions/float-sum.test.ts index 8839917..6216bef 100644 --- a/svelte-test/tests/database-functions/float-sum.test.ts +++ b/svelte-test/tests/database-functions/float-sum.test.ts @@ -2,11 +2,16 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { createTestDatabase, cleanupDatabase, - assertions, PerformanceTracker, } from "../fixtures/test-helpers.js"; import type { SQLiteWasmDatabase } from "@rainlanguage/sqlite-web"; -import { Float } from "@rainlanguage/float"; +import { + createFloatHexMap, + decodeFloatHex, + encodeFloatHex, + toMixedCase, + withoutPrefix, +} from "../fixtures/float-utils"; interface CategoryRow { category: string; @@ -22,54 +27,17 @@ describe("FLOAT_SUM Database Function", () => { let db: SQLiteWasmDatabase; let perf: PerformanceTracker; - function decodeFloatHex(hex: string): string { - const floatRes = Float.fromHex(hex as `0x${string}`); - if (floatRes.error) { - throw new Error(`fromHex failed: ${String(floatRes.error)}`); - } - const fmtRes = floatRes.value.format(); - if (fmtRes.error) { - throw new Error(`format failed: ${String(fmtRes.error)}`); - } - return fmtRes.value as string; - } - - function encodeFloatHex(decimal: string): `0x${string}` { - const parseRes = Float.parse(decimal); - if (parseRes.error) { - throw new Error(`Float.parse failed: ${String(parseRes.error.msg ?? parseRes.error)}`); - } - return parseRes.value.asHex(); - } - - function withoutPrefix(hex: `0x${string}`): string { - return hex.slice(2); - } - - function toMixedCase(hex: `0x${string}`): string { - let result = ""; - for (let i = 0; i < hex.length; i++) { - const char = hex[i]; - if (/[a-f]/.test(char)) { - result += i % 2 === 0 ? char.toUpperCase() : char.toLowerCase(); - } else { - result += char; - } - } - return result; - } - - const floatHex = { - zero: encodeFloatHex("0"), - zeroPointOne: encodeFloatHex("0.1"), - zeroPointFive: encodeFloatHex("0.5"), - onePointFive: encodeFloatHex("1.5"), - twoPointTwoFive: encodeFloatHex("2.25"), - ten: encodeFloatHex("10"), - twenty: encodeFloatHex("20"), - hundredPointTwentyFive: encodeFloatHex("100.25"), - oneHundredTwentyThreePointFourFiveSix: encodeFloatHex("123.456"), - } as const; + const floatHex = createFloatHexMap({ + zero: "0", + zeroPointOne: "0.1", + zeroPointFive: "0.5", + onePointFive: "1.5", + twoPointTwoFive: "2.25", + ten: "10", + twenty: "20", + hundredPointTwentyFive: "100.25", + oneHundredTwentyThreePointFourFiveSix: "123.456", + } as const); beforeEach(async () => { db = await createTestDatabase(); @@ -416,8 +384,7 @@ describe("FLOAT_SUM Database Function", () => { it("should handle bulk aggregation efficiently", async () => { const values = Array.from( { length: 10 }, - () => - `('${floatHex.zeroPointOne}', 'bulk')`, + () => `('${floatHex.zeroPointOne}', 'bulk')`, ).join(","); await db.query(` diff --git a/svelte-test/tests/fixtures/float-utils.ts b/svelte-test/tests/fixtures/float-utils.ts new file mode 100644 index 0000000..1ee1ac9 --- /dev/null +++ b/svelte-test/tests/fixtures/float-utils.ts @@ -0,0 +1,56 @@ +import { Float } from '@rainlanguage/float'; + +export type PrefixedHex = `0x${string}`; + +export function encodeFloatHex(decimal: string): PrefixedHex { + const parseRes = Float.parse(decimal); + if (parseRes.error) { + throw new Error(`Float.parse failed: ${String(parseRes.error.msg ?? parseRes.error)}`); + } + return parseRes.value.asHex() as PrefixedHex; +} + +function ensurePrefixedHex(hex: string): PrefixedHex { + if (!hex.startsWith('0x')) { + throw new Error(`Expected Float hex with 0x prefix, received: ${hex}`); + } + return hex as PrefixedHex; +} + +export function decodeFloatHex(hex: PrefixedHex | string): string { + const prefixed = ensurePrefixedHex(hex); + const fromHexRes = Float.fromHex(prefixed); + if (fromHexRes.error) { + throw new Error(`Float.fromHex failed: ${String(fromHexRes.error.msg ?? fromHexRes.error)}`); + } + const formatRes = fromHexRes.value.format(); + if (formatRes.error) { + throw new Error(`Float.format failed: ${String(formatRes.error.msg ?? formatRes.error)}`); + } + return formatRes.value as string; +} + +export function withoutPrefix(hex: PrefixedHex): string { + return hex.slice(2); +} + +export function toMixedCase(hex: PrefixedHex): string { + let result = ''; + for (let i = 0; i < hex.length; i++) { + const char = hex[i] ?? ''; + if (/[a-f]/.test(char)) { + result += i % 2 === 0 ? char.toUpperCase() : char.toLowerCase(); + } else { + result += char; + } + } + return result; +} + +export function createFloatHexMap>(decimals: T): { readonly [K in keyof T]: PrefixedHex } { + const result: Partial> = {}; + for (const [key, value] of Object.entries(decimals) as Array<[keyof T, string]>) { + result[key] = encodeFloatHex(value); + } + return result as { readonly [K in keyof T]: PrefixedHex }; +}