use serde_json::{Number, Value};
use crate::{
GatewayUpdateRequest, extract_update_payload, normalize_column_name,
parse_conditions_from_body, parse_room_id_value, qualify_gateway_table_name,
schema_name_from_body, write_right_for_resource,
};
#[derive(Debug, Clone)]
pub struct GatewayUpdateRequestPlan {
pub request: GatewayUpdateRequest,
pub qualified_table_name: String,
pub required_write_right: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GatewayUpdateRequestPlanError {
MissingTableName,
InvalidSchemaTableSelector(String),
MissingUpdatePayload,
InvalidConditionValue(String),
MissingConditions,
}
impl GatewayUpdateRequestPlanError {
pub const fn summary(&self) -> &'static str {
match self {
Self::MissingTableName => "Missing required field",
Self::InvalidSchemaTableSelector(_) => "Invalid schema/table selector",
Self::MissingUpdatePayload => "Missing update payload",
Self::InvalidConditionValue(_) => "Invalid condition value",
Self::MissingConditions => "Missing conditions",
}
}
pub fn detail(&self) -> String {
match self {
Self::MissingTableName => "table_name is required".to_string(),
Self::InvalidSchemaTableSelector(message)
| Self::InvalidConditionValue(message) => message.clone(),
Self::MissingUpdatePayload => "update payload required: provide 'columns' (array of objects with column names and values), or 'data' / 'set' object".to_string(),
Self::MissingConditions => {
"at least one condition is required for update (e.g. eq_column / eq_value)"
.to_string()
}
}
}
}
fn write_resource_name_for_table(table_name: &str) -> &str {
table_name.rsplit('.').next().unwrap_or(table_name).trim()
}
pub fn build_gateway_update_request_plan(
body: &Value,
force_camel_case_to_snake_case: bool,
) -> Result<GatewayUpdateRequestPlan, GatewayUpdateRequestPlanError> {
let table_name = body
.get("table_name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or(GatewayUpdateRequestPlanError::MissingTableName)?
.to_string();
let schema_name = schema_name_from_body(body);
let qualified_table_name = qualify_gateway_table_name(&table_name, schema_name.as_deref())
.map_err(GatewayUpdateRequestPlanError::InvalidSchemaTableSelector)?;
let set_payload = Value::Object(
extract_update_payload(body, force_camel_case_to_snake_case)
.ok_or(GatewayUpdateRequestPlanError::MissingUpdatePayload)?,
);
if let Some(additional_conditions) = body.get("conditions").and_then(Value::as_array) {
for condition in additional_conditions {
let Some(eq_column) = condition.get("eq_column").and_then(Value::as_str) else {
continue;
};
let normalized_for_validation =
normalize_column_name(eq_column, force_camel_case_to_snake_case);
if (normalized_for_validation == "room_id" || eq_column == "roomId")
&& condition.get("eq_value").is_none()
{
return Err(GatewayUpdateRequestPlanError::InvalidConditionValue(
"room_id is required and must be numeric".to_string(),
));
}
}
}
let mut conditions = parse_conditions_from_body(body)
.into_iter()
.map(|condition| {
let normalized_for_validation =
normalize_column_name(&condition.eq_column, force_camel_case_to_snake_case);
let eq_value = if normalized_for_validation == "room_id"
|| condition.eq_column == "roomId"
{
let room_id = parse_room_id_value(&condition.eq_value).map_err(|err| {
GatewayUpdateRequestPlanError::InvalidConditionValue(err.message().to_string())
})?;
Value::Number(Number::from(room_id))
} else {
condition.eq_value
};
Ok(crate::GatewayRequestCondition::new(
condition.eq_column,
eq_value,
))
})
.collect::<Result<Vec<_>, GatewayUpdateRequestPlanError>>()?;
if conditions.is_empty() {
return Err(GatewayUpdateRequestPlanError::MissingConditions);
}
conditions.sort_by(|a, b| a.eq_column.cmp(&b.eq_column));
let request = GatewayUpdateRequest {
table_name: table_name.clone(),
schema_name,
conditions,
data: set_payload,
};
Ok(GatewayUpdateRequestPlan {
request,
qualified_table_name,
required_write_right: write_right_for_resource(Some(write_resource_name_for_table(
&table_name,
))),
})
}
#[cfg(test)]
mod tests {
use super::{GatewayUpdateRequestPlanError, build_gateway_update_request_plan};
use serde_json::{Number, Value, json};
#[test]
fn update_plan_requires_table_name() {
let err = build_gateway_update_request_plan(
&json!({
"data": { "status": "active" },
"conditions": [{ "eq_column": "id", "eq_value": "1" }]
}),
false,
)
.expect_err("missing table_name should fail");
assert_eq!(err, GatewayUpdateRequestPlanError::MissingTableName);
assert_eq!(err.summary(), "Missing required field");
assert_eq!(err.detail(), "table_name is required");
}
#[test]
fn update_plan_rejects_invalid_schema_table_selectors() {
let err = build_gateway_update_request_plan(
&json!({
"table_name": "public.users",
"schema_name": "analytics",
"data": { "status": "active" },
"conditions": [{ "eq_column": "id", "eq_value": "1" }]
}),
false,
)
.expect_err("ambiguous schema/table should fail");
match err {
GatewayUpdateRequestPlanError::InvalidSchemaTableSelector(message) => {
assert!(message.contains("must not include a schema prefix"));
}
other => panic!("expected invalid schema/table selector, got {other:?}"),
}
}
#[test]
fn update_plan_requires_update_payload() {
let err = build_gateway_update_request_plan(
&json!({
"table_name": "users",
"conditions": [{ "eq_column": "id", "eq_value": "1" }]
}),
false,
)
.expect_err("missing update payload should fail");
assert_eq!(err, GatewayUpdateRequestPlanError::MissingUpdatePayload);
assert_eq!(err.summary(), "Missing update payload");
}
#[test]
fn update_plan_rejects_room_id_without_value() {
let err = build_gateway_update_request_plan(
&json!({
"table_name": "rooms",
"data": { "status": "active" },
"conditions": [{ "eq_column": "roomId" }]
}),
true,
)
.expect_err("missing room_id value should fail");
assert_eq!(
err,
GatewayUpdateRequestPlanError::InvalidConditionValue(
"room_id is required and must be numeric".to_string()
)
);
}
#[test]
fn update_plan_requires_conditions() {
let err = build_gateway_update_request_plan(
&json!({
"table_name": "users",
"data": { "status": "active" }
}),
false,
)
.expect_err("missing conditions should fail");
assert_eq!(err, GatewayUpdateRequestPlanError::MissingConditions);
assert_eq!(
err.detail(),
"at least one condition is required for update (e.g. eq_column / eq_value)"
);
}
#[test]
fn update_plan_coerces_room_id_sorts_conditions_and_derives_table_write_right() {
let plan = build_gateway_update_request_plan(
&json!({
"table_name": "rooms",
"schema_name": "public",
"set": { "status": "active" },
"conditions": [
{ "eq_column": "roomId", "eq_value": "42" },
{ "eq_column": "name", "eq_value": "lobby" }
]
}),
true,
)
.expect("plan should parse");
assert_eq!(plan.qualified_table_name, "public.rooms");
assert_eq!(plan.required_write_right, "rooms.write");
assert_eq!(plan.request.data, json!({ "status": "active" }));
assert_eq!(
plan.request.conditions,
vec![
crate::GatewayRequestCondition::new(
"name".to_string(),
Value::String("lobby".to_string())
),
crate::GatewayRequestCondition::new(
"roomId".to_string(),
Value::Number(Number::from(42))
),
]
);
}
}