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}