use serde::{Deserialize, Serialize};
use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::Arc;
use wasm_bindgen::prelude::*;
use crate::schema_cache::{ForeignKey, SchemaCache, FK_INTROSPECTION_QUERY};
#[cfg(feature = "wasm")]
use console_error_panic_hook;
thread_local! {
static SCHEMA_STORE: RefCell<HashMap<String, Arc<SchemaCache>>> =
RefCell::new(HashMap::new());
}
const DEFAULT_SCHEMA_KEY: &str = "default";
fn get_schema_cache(schema_id: Option<&str>) -> Option<Arc<SchemaCache>> {
let key = schema_id.unwrap_or(DEFAULT_SCHEMA_KEY);
SCHEMA_STORE.with(|store| store.borrow().get(key).cloned())
}
#[cfg(feature = "wasm")]
#[wasm_bindgen(start)]
pub fn init_panic_hook() {
console_error_panic_hook::set_once();
}
#[wasm_bindgen]
#[derive(Serialize, Deserialize)]
pub struct WasmQueryResult {
query: String,
params: Vec<serde_json::Value>,
tables: Vec<String>,
}
#[wasm_bindgen]
impl WasmQueryResult {
#[wasm_bindgen(getter)]
pub fn query(&self) -> String {
self.query.clone()
}
#[wasm_bindgen(getter)]
pub fn params(&self) -> JsValue {
serde_wasm_bindgen::to_value(&self.params).unwrap_or(JsValue::NULL)
}
#[wasm_bindgen(getter)]
pub fn tables(&self) -> JsValue {
serde_wasm_bindgen::to_value(&self.tables).unwrap_or(JsValue::NULL)
}
#[wasm_bindgen(js_name = toJSON)]
pub fn to_json(&self) -> JsValue {
serde_wasm_bindgen::to_value(self).unwrap_or(JsValue::NULL)
}
}
impl From<crate::sql::QueryResult> for WasmQueryResult {
fn from(r: crate::sql::QueryResult) -> Self {
Self {
query: r.query,
params: r.params,
tables: r.tables,
}
}
}
#[wasm_bindgen(js_name = parseQueryString)]
pub fn parse_query_string_wasm(
table: &str,
query_string: &str,
schema_id: Option<String>,
) -> Result<WasmQueryResult, JsValue> {
parse_request_wasm("GET", table, query_string, None, None, schema_id)
}
#[wasm_bindgen(js_name = parseOnly)]
pub fn parse_only_wasm(query_string: &str) -> Result<JsValue, JsValue> {
let params = crate::parse_query_string(query_string)
.map_err(|e| JsValue::from_str(&format!("Parse error: {}", e)))?;
serde_wasm_bindgen::to_value(¶ms)
.map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))
}
#[wasm_bindgen(js_name = buildFilterClause)]
pub fn build_filter_clause_wasm(filters_json: JsValue) -> Result<JsValue, JsValue> {
let filters: Vec<crate::LogicCondition> = serde_wasm_bindgen::from_value(filters_json)
.map_err(|e| JsValue::from_str(&format!("Invalid filters JSON: {}", e)))?;
let result = crate::build_filter_clause(&filters)
.map_err(|e| JsValue::from_str(&format!("Filter clause error: {}", e)))?;
serde_wasm_bindgen::to_value(&result)
.map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))
}
#[wasm_bindgen(js_name = parseInsert)]
pub fn parse_insert_wasm(
table: &str,
body: &str,
query_string: Option<String>,
headers: Option<String>,
schema_id: Option<String>,
) -> Result<WasmQueryResult, JsValue> {
parse_request_wasm(
"POST",
table,
&query_string.unwrap_or_default(),
Some(body.to_string()),
headers,
schema_id,
)
}
#[wasm_bindgen(js_name = parseUpdate)]
pub fn parse_update_wasm(
table: &str,
body: &str,
query_string: &str,
headers: Option<String>,
schema_id: Option<String>,
) -> Result<WasmQueryResult, JsValue> {
parse_request_wasm(
"PATCH",
table,
query_string,
Some(body.to_string()),
headers,
schema_id,
)
}
#[wasm_bindgen(js_name = parseDelete)]
pub fn parse_delete_wasm(
table: &str,
query_string: &str,
headers: Option<String>,
schema_id: Option<String>,
) -> Result<WasmQueryResult, JsValue> {
parse_request_wasm("DELETE", table, query_string, None, headers, schema_id)
}
#[wasm_bindgen(js_name = parseRpc)]
pub fn parse_rpc_wasm(
function_name: &str,
body: Option<String>,
query_string: Option<String>,
headers: Option<String>,
schema_id: Option<String>,
) -> Result<WasmQueryResult, JsValue> {
let path = format!("rpc/{}", function_name);
parse_request_wasm(
"POST",
&path,
&query_string.unwrap_or_default(),
body,
headers,
schema_id,
)
}
#[wasm_bindgen(js_name = parseRequest)]
pub fn parse_request_wasm(
method: &str,
path: &str,
query_string: &str,
body: Option<String>,
headers: Option<String>,
schema_id: Option<String>,
) -> Result<WasmQueryResult, JsValue> {
let headers_map: Option<std::collections::HashMap<String, String>> = if let Some(h) = headers {
serde_json::from_str(&h).ok()
} else {
None
};
let operation = crate::parse(
method,
path,
query_string,
body.as_deref(),
headers_map.as_ref(),
)
.map_err(|e| JsValue::from_str(&format!("Parse error: {}", e)))?;
let cache = get_schema_cache(schema_id.as_deref());
let result = crate::operation_to_sql_with_cache(path, &operation, cache)
.map_err(|e| JsValue::from_str(&format!("SQL generation error: {}", e)))?;
Ok(result.into())
}
#[wasm_bindgen(js_name = initSchemaFromDb)]
pub async fn init_schema_from_db(
schema_id: &str,
query_executor: js_sys::Function,
) -> Result<(), JsValue> {
let key = if schema_id.is_empty() {
DEFAULT_SCHEMA_KEY.to_string()
} else {
schema_id.to_string()
};
let this = JsValue::null();
let sql_arg = JsValue::from_str(FK_INTROSPECTION_QUERY);
let promise = query_executor
.call1(&this, &sql_arg)
.map_err(|e| JsValue::from_str(&format!("Query executor call failed: {:?}", e)))?;
let result = wasm_bindgen_futures::JsFuture::from(js_sys::Promise::from(promise))
.await
.map_err(|e| JsValue::from_str(&format!("Query execution failed: {:?}", e)))?;
let rows = js_sys::Reflect::get(&result, &JsValue::from_str("rows"))
.map_err(|e| JsValue::from_str(&format!("Failed to get rows from result: {:?}", e)))?;
let rows_array = js_sys::Array::from(&rows);
let mut foreign_keys = Vec::new();
fn get_string_field(row: &JsValue, name: &str) -> Option<String> {
js_sys::Reflect::get(row, &JsValue::from_str(name))
.ok()
.and_then(|v| v.as_string())
.filter(|s| !s.is_empty())
}
for i in 0..rows_array.length() {
let row = rows_array.get(i);
let fk = match (
get_string_field(&row, "constraint_name"),
get_string_field(&row, "from_schema"),
get_string_field(&row, "from_table"),
get_string_field(&row, "from_column"),
get_string_field(&row, "to_schema"),
get_string_field(&row, "to_table"),
get_string_field(&row, "to_column"),
) {
(
Some(constraint_name),
Some(from_schema),
Some(from_table),
Some(from_column),
Some(to_schema),
Some(to_table),
Some(to_column),
) => ForeignKey {
constraint_name,
from_schema,
from_table,
from_column,
to_schema,
to_table,
to_column,
},
_ => continue, };
foreign_keys.push(fk);
}
let cache = SchemaCache::from_foreign_keys(foreign_keys);
SCHEMA_STORE.with(|store| {
store.borrow_mut().insert(key, Arc::new(cache));
});
Ok(())
}
#[wasm_bindgen(js_name = clearSchema)]
pub fn clear_schema(schema_id: &str) {
let key = if schema_id.is_empty() {
DEFAULT_SCHEMA_KEY
} else {
schema_id
};
SCHEMA_STORE.with(|store| {
store.borrow_mut().remove(key);
});
}
#[wasm_bindgen(js_name = clearAllSchemas)]
pub fn clear_all_schemas() {
SCHEMA_STORE.with(|store| {
store.borrow_mut().clear();
});
}
#[cfg(test)]
mod tests {
use super::*;
struct TestSchema {
id: String,
}
impl TestSchema {
fn new(schema_id: &str, fks: Vec<ForeignKey>) -> Self {
let cache = SchemaCache::from_foreign_keys(fks);
SCHEMA_STORE.with(|store| {
store
.borrow_mut()
.insert(schema_id.to_string(), Arc::new(cache));
});
Self {
id: schema_id.to_string(),
}
}
}
impl Drop for TestSchema {
fn drop(&mut self) {
clear_schema(&self.id);
}
}
fn parse_with_schema(
method: &str,
path: &str,
query_string: &str,
body: Option<&str>,
schema_id: Option<&str>,
) -> Result<crate::sql::QueryResult, crate::error::Error> {
let operation = crate::parse(method, path, query_string, body, None)?;
let cache = get_schema_cache(schema_id);
crate::operation_to_sql_with_cache(path, &operation, cache)
}
#[test]
fn test_clear_schema() {
let _guard = TestSchema::new("test-clear", vec![]);
assert!(get_schema_cache(Some("test-clear")).is_some());
clear_schema("test-clear");
assert!(get_schema_cache(Some("test-clear")).is_none());
}
#[test]
fn test_clear_all_schemas() {
let _a = TestSchema::new("clear-a", vec![]);
let _b = TestSchema::new("clear-b", vec![]);
assert!(get_schema_cache(Some("clear-a")).is_some());
assert!(get_schema_cache(Some("clear-b")).is_some());
clear_all_schemas();
assert!(get_schema_cache(Some("clear-a")).is_none());
assert!(get_schema_cache(Some("clear-b")).is_none());
}
#[test]
fn test_get_schema_cache_returns_none_for_unknown_key() {
assert!(get_schema_cache(Some("nonexistent-key")).is_none());
}
#[test]
fn test_empty_schema_id_maps_to_default() {
let _guard = TestSchema::new("default", vec![]);
assert!(get_schema_cache(None).is_some());
assert!(get_schema_cache(Some("default")).is_some());
}
#[test]
fn test_select_with_schema_id_resolves_many_to_one() {
let _guard = TestSchema::new(
"sel-m2o",
vec![ForeignKey::test("orders", "customer_id", "customers", "id")],
);
let result = parse_with_schema(
"GET",
"orders",
"select=id,customers(name)",
None,
Some("sel-m2o"),
)
.unwrap();
assert!(result.query.contains("customers"));
assert!(result.query.contains("customer_id"));
}
#[test]
fn test_select_with_schema_id_resolves_one_to_many() {
let _guard = TestSchema::new(
"sel-o2m",
vec![ForeignKey::test("orders", "customer_id", "customers", "id")],
);
let result = parse_with_schema(
"GET",
"customers",
"select=id,orders(id)",
None,
Some("sel-o2m"),
)
.unwrap();
assert!(result.query.contains("orders"));
assert!(result.query.contains("json_agg"));
}
#[test]
fn test_insert_with_schema_id() {
let _guard = TestSchema::new("ins-tenant", vec![]);
let result = parse_with_schema(
"POST",
"users",
"",
Some(r#"{"name":"Bob"}"#),
Some("ins-tenant"),
)
.unwrap();
assert!(result.query.contains("INSERT"));
}
#[test]
fn test_update_with_schema_id() {
let _guard = TestSchema::new("upd-tenant", vec![]);
let result = parse_with_schema(
"PATCH",
"users",
"id=eq.1",
Some(r#"{"status":"active"}"#),
Some("upd-tenant"),
)
.unwrap();
assert!(result.query.contains("UPDATE"));
}
#[test]
fn test_delete_with_schema_id() {
let _guard = TestSchema::new("del-tenant", vec![]);
let result = parse_with_schema(
"DELETE",
"users",
"id=eq.1",
None,
Some("del-tenant"),
)
.unwrap();
assert!(result.query.contains("DELETE"));
}
#[test]
fn test_rpc_with_schema_id() {
let _guard = TestSchema::new("rpc-tenant", vec![]);
let result = parse_with_schema(
"POST",
"rpc/my_func",
"",
Some(r#"{"x": 1}"#),
Some("rpc-tenant"),
)
.unwrap();
assert!(result.query.contains("my_func"));
}
#[test]
fn test_two_tenants_different_schemas() {
let _a = TestSchema::new(
"iso-a",
vec![ForeignKey::test("posts", "author_id", "users", "id")],
);
let _b = TestSchema::new(
"iso-b",
vec![ForeignKey::test("orders", "product_id", "products", "id")],
);
let a = parse_with_schema(
"GET",
"posts",
"select=title,users(name)",
None,
Some("iso-a"),
)
.unwrap();
assert!(a.query.contains("author_id"));
let b = parse_with_schema(
"GET",
"orders",
"select=id,products(name)",
None,
Some("iso-b"),
)
.unwrap();
assert!(b.query.contains("product_id"));
let a_wrong = parse_with_schema(
"GET",
"orders",
"select=id,products(name)",
None,
Some("iso-a"),
);
assert!(a_wrong.is_err());
let b_wrong = parse_with_schema(
"GET",
"posts",
"select=title,users(name)",
None,
Some("iso-b"),
);
assert!(b_wrong.is_err());
}
#[test]
fn test_no_schema_id_uses_default_cache() {
let _guard = TestSchema::new(
"default",
vec![ForeignKey::test("posts", "user_id", "users", "id")],
);
let result = parse_with_schema(
"GET",
"posts",
"select=title,users(name)",
None,
None,
)
.unwrap();
assert!(result.query.contains("user_id"));
}
#[test]
fn test_clear_schema_removes_relation_resolution() {
let _guard = TestSchema::new(
"evict-me",
vec![ForeignKey::test("orders", "customer_id", "customers", "id")],
);
let before = parse_with_schema(
"GET",
"orders",
"select=id,customers(name)",
None,
Some("evict-me"),
)
.unwrap();
assert!(before.query.contains("customer_id"));
clear_schema("evict-me");
let after = parse_with_schema(
"GET",
"orders",
"select=id,customers(name)",
None,
Some("evict-me"),
)
.unwrap();
assert!(!after.query.contains("customer_id"));
}
#[test]
fn test_from_foreign_keys_skips_empty_fields() {
let valid_fk = ForeignKey::test("orders", "customer_id", "customers", "id");
let invalid_fk = ForeignKey {
from_schema: "public".to_string(),
from_table: "".to_string(), from_column: "bad_col".to_string(),
to_schema: "public".to_string(),
to_table: "other".to_string(),
to_column: "id".to_string(),
constraint_name: "bad_fkey".to_string(),
};
let cache = SchemaCache::from_foreign_keys(vec![valid_fk, invalid_fk]);
let rel = cache.find_relationship("public", "orders", "customers");
assert!(rel.is_some());
let bad = cache.find_relationship("public", "", "other");
assert!(bad.is_some());
let no_match = cache.find_relationship("public", "valid_table", "other");
assert!(no_match.is_none());
}
#[test]
fn test_parse_without_schema_id_works() {
let result = parse_with_schema(
"GET",
"users",
"age=gte.18&limit=10",
None,
None,
)
.unwrap();
assert!(result.query.contains("SELECT"));
assert!(result.query.contains("users"));
assert!(result.query.contains("WHERE"));
assert!(result.query.contains("LIMIT"));
}
}