Skip to main content

athena_gateway/
update_plan.rs

1//! Portable `/gateway/update` request planning helpers.
2//!
3//! This module owns the body-based request-domain logic that can be shared
4//! across route adapters: qualified table resolution, update payload
5//! extraction, `room_id` validation/coercion, condition sorting, and derived
6//! table-based write-right calculation. Runtime adapters keep auth fallback
7//! ordering, deferred queueing, pool resolution, execution, logging, and HTTP
8//! response construction.
9
10use 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/// Portable request plan derived from a canonical `/gateway/update` payload.
19#[derive(Debug, Clone)]
20pub struct GatewayUpdateRequestPlan {
21    /// Canonical update request with validated conditions and normalized SET
22    /// payload.
23    pub request: GatewayUpdateRequest,
24    /// Qualified `schema.table` target used for downstream SQL execution.
25    pub qualified_table_name: String,
26    /// Table-name-based gateway write right derived from the request target.
27    pub required_write_right: String,
28}
29
30/// Validation errors for portable `/gateway/update` planning.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum GatewayUpdateRequestPlanError {
33    /// The request did not include `table_name`.
34    MissingTableName,
35    /// The table selector or schema override was invalid.
36    InvalidSchemaTableSelector(String),
37    /// The request did not include a valid update payload.
38    MissingUpdatePayload,
39    /// A condition value failed gateway compatibility validation.
40    InvalidConditionValue(String),
41    /// The request did not include any conditions.
42    MissingConditions,
43}
44
45impl GatewayUpdateRequestPlanError {
46    /// Returns the stable public summary used by route adapters.
47    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    /// Returns the stable public detail string used by route adapters.
58    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
76/// Builds the portable `/gateway/update` execution plan from a parsed JSON body.
77pub 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}