Skip to main content

template_model/
dynamodb.rs

1use crate::SemanticModel;
2use crate::resolved_value_to_json;
3use crate::resolver::ResolvedValue;
4use serde_json::{Map, Value};
5use std::collections::{BTreeSet, HashMap};
6
7const ATTRIBUTE_DEFINITIONS: &str = "AttributeDefinitions";
8const ATTRIBUTE_NAME: &str = "AttributeName";
9const BILLING_MODE: &str = "BillingMode";
10const GLOBAL_SECONDARY_INDEXES: &str = "GlobalSecondaryIndexes";
11const KEY_SCHEMA: &str = "KeySchema";
12const LOCAL_SECONDARY_INDEXES: &str = "LocalSecondaryIndexes";
13const PROVISIONED_THROUGHPUT: &str = "ProvisionedThroughput";
14
15#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
16pub struct DynamoDbAttributeMismatch {
17    pub missing: Vec<String>,
18    pub unused: Vec<String>,
19}
20
21#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct DynamoDbScenarioAnalysis {
23    pub attribute_mismatches: Vec<DynamoDbAttributeMismatch>,
24    pub explicit_provisioned_missing_throughput: bool,
25    pub default_provisioned_missing_throughput: bool,
26}
27
28pub fn analyze_dynamodb_table_scenarios(model: &SemanticModel, resource_id: &str) -> DynamoDbScenarioAnalysis {
29    let mut analysis = DynamoDbScenarioAnalysis::default();
30    let mut attribute_mismatches = BTreeSet::new();
31
32    let scenarios = model.resolve_properties_scenarios_projected(
33        resource_id,
34        &[
35            ATTRIBUTE_DEFINITIONS,
36            BILLING_MODE,
37            GLOBAL_SECONDARY_INDEXES,
38            KEY_SCHEMA,
39            LOCAL_SECONDARY_INDEXES,
40            PROVISIONED_THROUGHPUT,
41        ],
42    );
43    for (properties, conditions) in scenarios.iter() {
44        if !scenario_is_reachable(model, resource_id, conditions) {
45            continue;
46        }
47        let Some(properties) = properties_object(properties) else {
48            continue;
49        };
50
51        let throughput_missing = effective_property(&properties, PROVISIONED_THROUGHPUT).is_none();
52        match effective_property(&properties, BILLING_MODE) {
53            None if throughput_missing => analysis.default_provisioned_missing_throughput = true,
54            Some(Value::String(mode)) if mode == "PROVISIONED" && throughput_missing => {
55                analysis.explicit_provisioned_missing_throughput = true;
56            }
57            _ => {}
58        }
59
60        let Some(defined) = attribute_definitions(properties.get(ATTRIBUTE_DEFINITIONS)) else {
61            continue;
62        };
63        let mut referenced = BTreeSet::new();
64        let table_schema_complete = collect_key_schema(properties.get(KEY_SCHEMA), &mut referenced);
65        let global_indexes_complete =
66            collect_index_key_schemas(properties.get(GLOBAL_SECONDARY_INDEXES), &mut referenced);
67        let local_indexes_complete =
68            collect_index_key_schemas(properties.get(LOCAL_SECONDARY_INDEXES), &mut referenced);
69
70        let missing: Vec<String> = referenced.difference(&defined).cloned().collect();
71        let unused: Vec<String> = if table_schema_complete && global_indexes_complete && local_indexes_complete {
72            defined.difference(&referenced).cloned().collect()
73        } else {
74            Vec::new()
75        };
76        if !missing.is_empty() || !unused.is_empty() {
77            attribute_mismatches.insert(DynamoDbAttributeMismatch { missing, unused });
78        }
79    }
80
81    analysis.attribute_mismatches = attribute_mismatches.into_iter().collect();
82    analysis
83}
84
85fn properties_object(properties: &ResolvedValue) -> Option<Map<String, Value>> {
86    match properties {
87        ResolvedValue::Map { .. } | ResolvedValue::Concrete { .. } => {
88            resolved_value_to_json(properties).as_object().cloned()
89        }
90        _ => None,
91    }
92}
93
94fn effective_property<'a>(properties: &'a Map<String, Value>, name: &str) -> Option<&'a Value> {
95    properties.get(name).filter(|value| !value.is_null())
96}
97
98fn attribute_definitions(value: Option<&Value>) -> Option<BTreeSet<String>> {
99    let definitions = value?.as_array()?;
100    let mut names = BTreeSet::new();
101    for definition in definitions {
102        if definition.is_null() {
103            continue;
104        }
105        names.insert(definition.get(ATTRIBUTE_NAME)?.as_str()?.to_string());
106    }
107    Some(names)
108}
109
110fn collect_key_schema(value: Option<&Value>, referenced: &mut BTreeSet<String>) -> bool {
111    let Some(keys) = value.and_then(Value::as_array) else {
112        return false;
113    };
114    let mut complete = true;
115    for key in keys {
116        if key.is_null() {
117            continue;
118        }
119        if let Some(name) = key.get(ATTRIBUTE_NAME).and_then(Value::as_str) {
120            referenced.insert(name.to_string());
121        } else {
122            complete = false;
123        }
124    }
125    complete
126}
127
128fn collect_index_key_schemas(value: Option<&Value>, referenced: &mut BTreeSet<String>) -> bool {
129    let Some(value) = value else { return true };
130    if value.is_null() {
131        return true;
132    }
133    let Some(indexes) = value.as_array() else {
134        return false;
135    };
136    let mut complete = true;
137    for index in indexes {
138        if index.is_null() {
139            continue;
140        }
141        if !collect_key_schema(index.get(KEY_SCHEMA), referenced) {
142            complete = false;
143        }
144    }
145    complete
146}
147
148fn scenario_is_reachable(model: &SemanticModel, resource_id: &str, conditions: &HashMap<String, bool>) -> bool {
149    let mut assumptions: Vec<(String, bool)> = conditions.iter().map(|(name, value)| (name.clone(), *value)).collect();
150    if let Some(resource_condition) = model.resources.get(resource_id).and_then(|resource| resource.condition.as_ref())
151    {
152        match conditions.get(resource_condition) {
153            Some(false) => return false,
154            Some(true) => {}
155            None => assumptions.push((resource_condition.clone(), true)),
156        }
157    }
158    model.conditions.is_satisfiable(&assumptions)
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    fn analyze(template: &str, resource_id: &str) -> DynamoDbScenarioAnalysis {
166        let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
167        analyze_dynamodb_table_scenarios(&model, resource_id)
168    }
169
170    #[test]
171    fn conditional_index_absence_exposes_unused_definitions() {
172        let analysis = analyze(
173            r#"
174Conditions:
175  HasIndex: !Equals [!Ref AWS::Region, us-east-1]
176Resources:
177  Table:
178    Type: AWS::DynamoDB::Table
179    Properties:
180      BillingMode: PAY_PER_REQUEST
181      AttributeDefinitions:
182        - {AttributeName: pk, AttributeType: S}
183        - {AttributeName: index_pk, AttributeType: S}
184      KeySchema: [{AttributeName: pk, KeyType: HASH}]
185      GlobalSecondaryIndexes: !If
186        - HasIndex
187        - - IndexName: by-index
188            KeySchema: [{AttributeName: index_pk, KeyType: HASH}]
189            Projection: {ProjectionType: ALL}
190        - !Ref AWS::NoValue
191"#,
192            "Table",
193        );
194        assert_eq!(
195            analysis.attribute_mismatches,
196            [DynamoDbAttributeMismatch { missing: vec![], unused: vec!["index_pk".to_string()] }]
197        );
198    }
199
200    #[test]
201    fn correlated_index_and_definitions_are_valid_in_every_world() {
202        let analysis = analyze(
203            r#"
204Conditions:
205  HasIndex: !Equals [!Ref AWS::Region, us-east-1]
206Resources:
207  Table:
208    Type: AWS::DynamoDB::Table
209    Properties:
210      BillingMode: PAY_PER_REQUEST
211      AttributeDefinitions: !If
212        - HasIndex
213        - - {AttributeName: pk, AttributeType: S}
214          - {AttributeName: index_pk, AttributeType: S}
215        - - {AttributeName: pk, AttributeType: S}
216      KeySchema: [{AttributeName: pk, KeyType: HASH}]
217      GlobalSecondaryIndexes: !If
218        - HasIndex
219        - - IndexName: by-index
220            KeySchema: [{AttributeName: index_pk, KeyType: HASH}]
221            Projection: {ProjectionType: ALL}
222        - !Ref AWS::NoValue
223"#,
224            "Table",
225        );
226        assert!(analysis.attribute_mismatches.is_empty());
227    }
228
229    #[test]
230    fn throughput_requirement_follows_whole_properties_branches() {
231        let template = r#"
232Conditions:
233  OnDemand: !Equals [!Ref AWS::Region, us-east-1]
234Resources:
235  Valid:
236    Type: AWS::DynamoDB::Table
237    Properties: !If
238      - OnDemand
239      - BillingMode: PAY_PER_REQUEST
240      - ProvisionedThroughput: {ReadCapacityUnits: 1, WriteCapacityUnits: 1}
241  Invalid:
242    Type: AWS::DynamoDB::Table
243    Properties: !If
244      - OnDemand
245      - BillingMode: PAY_PER_REQUEST
246      - {}
247"#;
248        let valid = analyze(template, "Valid");
249        assert!(!valid.explicit_provisioned_missing_throughput);
250        assert!(!valid.default_provisioned_missing_throughput);
251        let invalid = analyze(template, "Invalid");
252        assert!(!invalid.explicit_provisioned_missing_throughput);
253        assert!(invalid.default_provisioned_missing_throughput);
254    }
255
256    #[test]
257    fn unknown_index_content_preserves_known_missing_checks_but_not_unused_checks() {
258        let analysis = analyze(
259            r#"
260Parameters:
261  Indexes:
262    Type: String
263Resources:
264  Table:
265    Type: AWS::DynamoDB::Table
266    Properties:
267      BillingMode: PAY_PER_REQUEST
268      AttributeDefinitions:
269        - {AttributeName: pk, AttributeType: S}
270        - {AttributeName: maybe_used, AttributeType: S}
271      KeySchema:
272        - {AttributeName: pk, KeyType: HASH}
273        - {AttributeName: missing, KeyType: RANGE}
274      GlobalSecondaryIndexes: !Ref Indexes
275"#,
276            "Table",
277        );
278        assert_eq!(
279            analysis.attribute_mismatches,
280            [DynamoDbAttributeMismatch { missing: vec!["missing".to_string()], unused: vec![] }]
281        );
282    }
283
284    #[test]
285    fn resource_condition_excludes_incompatible_index_and_throughput_worlds() {
286        let template = r#"
287Conditions:
288  IsPresent: !Equals [!Ref AWS::Region, us-east-1]
289Resources:
290  Table:
291    Type: AWS::DynamoDB::Table
292    Condition: IsPresent
293    Properties: !If
294      - IsPresent
295      - BillingMode: PAY_PER_REQUEST
296        AttributeDefinitions:
297          - {AttributeName: pk, AttributeType: S}
298          - {AttributeName: index_pk, AttributeType: S}
299        KeySchema: [{AttributeName: pk, KeyType: HASH}]
300        GlobalSecondaryIndexes:
301          - IndexName: by-index
302            KeySchema: [{AttributeName: index_pk, KeyType: HASH}]
303            Projection: {ProjectionType: ALL}
304      - AttributeDefinitions:
305          - {AttributeName: pk, AttributeType: S}
306          - {AttributeName: index_pk, AttributeType: S}
307        KeySchema: [{AttributeName: pk, KeyType: HASH}]
308"#;
309        let analysis = analyze(template, "Table");
310        assert!(analysis.attribute_mismatches.is_empty());
311        assert!(!analysis.explicit_provisioned_missing_throughput);
312        assert!(!analysis.default_provisioned_missing_throughput);
313    }
314
315    #[test]
316    fn repeated_analysis_reuses_whole_properties_scenarios() {
317        let model = SemanticModel::from_bytes(
318            br#"{
319                "Conditions": {"OnDemand": {"Fn::Equals": [{"Ref": "AWS::Region"}, "us-east-1"]}},
320                "Resources": {"Table": {"Type": "AWS::DynamoDB::Table", "Properties": {"Fn::If": [
321                    "OnDemand",
322                    {"BillingMode": "PAY_PER_REQUEST"},
323                    {"ProvisionedThroughput": {"ReadCapacityUnits": 1, "WriteCapacityUnits": 1}}
324                ]}}}
325            }"#,
326        )
327        .unwrap();
328        let first = analyze_dynamodb_table_scenarios(&model, "Table");
329        let combinations_after_first = model.scenario_combinations_used();
330        assert!(combinations_after_first > 0);
331        let second = analyze_dynamodb_table_scenarios(&model, "Table");
332        assert_eq!(second, first);
333        assert_eq!(model.scenario_combinations_used(), combinations_after_first);
334    }
335}