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