1use serde_json::{Number, Value};
11
12use crate::{
13 GatewayUpdateRequest, extract_update_payload, normalize_column_name,
14 parse_conditions_from_body, parse_room_id_value, qualify_gateway_table_name,
15 schema_name_from_body, write_right_for_resource,
16};
17
18#[derive(Debug, Clone)]
20pub struct GatewayUpdateRequestPlan {
21 pub request: GatewayUpdateRequest,
24 pub qualified_table_name: String,
26 pub required_write_right: String,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum GatewayUpdateRequestPlanError {
33 MissingTableName,
35 InvalidSchemaTableSelector(String),
37 MissingUpdatePayload,
39 InvalidConditionValue(String),
41 MissingConditions,
43}
44
45impl GatewayUpdateRequestPlanError {
46 pub const fn summary(&self) -> &'static str {
48 match self {
49 Self::MissingTableName => "Missing required field",
50 Self::InvalidSchemaTableSelector(_) => "Invalid schema/table selector",
51 Self::MissingUpdatePayload => "Missing update payload",
52 Self::InvalidConditionValue(_) => "Invalid condition value",
53 Self::MissingConditions => "Missing conditions",
54 }
55 }
56
57 pub fn detail(&self) -> String {
59 match self {
60 Self::MissingTableName => "table_name is required".to_string(),
61 Self::InvalidSchemaTableSelector(message)
62 | Self::InvalidConditionValue(message) => message.clone(),
63 Self::MissingUpdatePayload => "update payload required: provide 'columns' (array of objects with column names and values), or 'data' / 'set' object".to_string(),
64 Self::MissingConditions => {
65 "at least one condition is required for update (e.g. eq_column / eq_value)"
66 .to_string()
67 }
68 }
69 }
70}
71
72fn write_resource_name_for_table(table_name: &str) -> &str {
73 table_name.rsplit('.').next().unwrap_or(table_name).trim()
74}
75
76pub fn build_gateway_update_request_plan(
78 body: &Value,
79 force_camel_case_to_snake_case: bool,
80) -> Result<GatewayUpdateRequestPlan, GatewayUpdateRequestPlanError> {
81 let table_name = body
82 .get("table_name")
83 .and_then(Value::as_str)
84 .map(str::trim)
85 .filter(|value| !value.is_empty())
86 .ok_or(GatewayUpdateRequestPlanError::MissingTableName)?
87 .to_string();
88
89 let schema_name = schema_name_from_body(body);
90 let qualified_table_name = qualify_gateway_table_name(&table_name, schema_name.as_deref())
91 .map_err(GatewayUpdateRequestPlanError::InvalidSchemaTableSelector)?;
92
93 let set_payload = Value::Object(
94 extract_update_payload(body, force_camel_case_to_snake_case)
95 .ok_or(GatewayUpdateRequestPlanError::MissingUpdatePayload)?,
96 );
97
98 if let Some(additional_conditions) = body.get("conditions").and_then(Value::as_array) {
99 for condition in additional_conditions {
100 let Some(eq_column) = condition.get("eq_column").and_then(Value::as_str) else {
101 continue;
102 };
103 let normalized_for_validation =
104 normalize_column_name(eq_column, force_camel_case_to_snake_case);
105 if (normalized_for_validation == "room_id" || eq_column == "roomId")
106 && condition.get("eq_value").is_none()
107 {
108 return Err(GatewayUpdateRequestPlanError::InvalidConditionValue(
109 "room_id is required and must be numeric".to_string(),
110 ));
111 }
112 }
113 }
114
115 let mut conditions = parse_conditions_from_body(body)
116 .into_iter()
117 .map(|condition| {
118 let normalized_for_validation =
119 normalize_column_name(&condition.eq_column, force_camel_case_to_snake_case);
120 let eq_value = if normalized_for_validation == "room_id"
121 || condition.eq_column == "roomId"
122 {
123 let room_id = parse_room_id_value(&condition.eq_value).map_err(|err| {
124 GatewayUpdateRequestPlanError::InvalidConditionValue(err.message().to_string())
125 })?;
126 Value::Number(Number::from(room_id))
127 } else {
128 condition.eq_value
129 };
130
131 Ok(crate::GatewayRequestCondition::new(
132 condition.eq_column,
133 eq_value,
134 ))
135 })
136 .collect::<Result<Vec<_>, GatewayUpdateRequestPlanError>>()?;
137
138 if conditions.is_empty() {
139 return Err(GatewayUpdateRequestPlanError::MissingConditions);
140 }
141 conditions.sort_by(|a, b| a.eq_column.cmp(&b.eq_column));
142
143 let request = GatewayUpdateRequest {
144 table_name: table_name.clone(),
145 schema_name,
146 conditions,
147 data: set_payload,
148 };
149
150 Ok(GatewayUpdateRequestPlan {
151 request,
152 qualified_table_name,
153 required_write_right: write_right_for_resource(Some(write_resource_name_for_table(
154 &table_name,
155 ))),
156 })
157}
158
159#[cfg(test)]
160mod tests {
161 use super::{GatewayUpdateRequestPlanError, build_gateway_update_request_plan};
162 use serde_json::{Number, Value, json};
163
164 #[test]
165 fn update_plan_requires_table_name() {
166 let err = build_gateway_update_request_plan(
167 &json!({
168 "data": { "status": "active" },
169 "conditions": [{ "eq_column": "id", "eq_value": "1" }]
170 }),
171 false,
172 )
173 .expect_err("missing table_name should fail");
174
175 assert_eq!(err, GatewayUpdateRequestPlanError::MissingTableName);
176 assert_eq!(err.summary(), "Missing required field");
177 assert_eq!(err.detail(), "table_name is required");
178 }
179
180 #[test]
181 fn update_plan_rejects_invalid_schema_table_selectors() {
182 let err = build_gateway_update_request_plan(
183 &json!({
184 "table_name": "public.users",
185 "schema_name": "analytics",
186 "data": { "status": "active" },
187 "conditions": [{ "eq_column": "id", "eq_value": "1" }]
188 }),
189 false,
190 )
191 .expect_err("ambiguous schema/table should fail");
192
193 match err {
194 GatewayUpdateRequestPlanError::InvalidSchemaTableSelector(message) => {
195 assert!(message.contains("must not include a schema prefix"));
196 }
197 other => panic!("expected invalid schema/table selector, got {other:?}"),
198 }
199 }
200
201 #[test]
202 fn update_plan_requires_update_payload() {
203 let err = build_gateway_update_request_plan(
204 &json!({
205 "table_name": "users",
206 "conditions": [{ "eq_column": "id", "eq_value": "1" }]
207 }),
208 false,
209 )
210 .expect_err("missing update payload should fail");
211
212 assert_eq!(err, GatewayUpdateRequestPlanError::MissingUpdatePayload);
213 assert_eq!(err.summary(), "Missing update payload");
214 }
215
216 #[test]
217 fn update_plan_rejects_room_id_without_value() {
218 let err = build_gateway_update_request_plan(
219 &json!({
220 "table_name": "rooms",
221 "data": { "status": "active" },
222 "conditions": [{ "eq_column": "roomId" }]
223 }),
224 true,
225 )
226 .expect_err("missing room_id value should fail");
227
228 assert_eq!(
229 err,
230 GatewayUpdateRequestPlanError::InvalidConditionValue(
231 "room_id is required and must be numeric".to_string()
232 )
233 );
234 }
235
236 #[test]
237 fn update_plan_requires_conditions() {
238 let err = build_gateway_update_request_plan(
239 &json!({
240 "table_name": "users",
241 "data": { "status": "active" }
242 }),
243 false,
244 )
245 .expect_err("missing conditions should fail");
246
247 assert_eq!(err, GatewayUpdateRequestPlanError::MissingConditions);
248 assert_eq!(
249 err.detail(),
250 "at least one condition is required for update (e.g. eq_column / eq_value)"
251 );
252 }
253
254 #[test]
255 fn update_plan_coerces_room_id_sorts_conditions_and_derives_table_write_right() {
256 let plan = build_gateway_update_request_plan(
257 &json!({
258 "table_name": "rooms",
259 "schema_name": "public",
260 "set": { "status": "active" },
261 "conditions": [
262 { "eq_column": "roomId", "eq_value": "42" },
263 { "eq_column": "name", "eq_value": "lobby" }
264 ]
265 }),
266 true,
267 )
268 .expect("plan should parse");
269
270 assert_eq!(plan.qualified_table_name, "public.rooms");
271 assert_eq!(plan.required_write_right, "rooms.write");
272 assert_eq!(plan.request.data, json!({ "status": "active" }));
273 assert_eq!(
274 plan.request.conditions,
275 vec![
276 crate::GatewayRequestCondition::new(
277 "name".to_string(),
278 Value::String("lobby".to_string())
279 ),
280 crate::GatewayRequestCondition::new(
281 "roomId".to_string(),
282 Value::Number(Number::from(42))
283 ),
284 ]
285 );
286 }
287}