Skip to main content

dynoxide/actions/
create_table.rs

1use crate::actions::{TableDescription, build_table_description};
2use crate::errors::{DynoxideError, Result};
3use crate::storage_backend::StorageBackend;
4use crate::streams;
5use crate::types::{
6    AttributeDefinition, GlobalSecondaryIndex, KeySchemaElement, KeyType, LocalSecondaryIndex,
7    Projection, ProjectionType, ProvisionedThroughput,
8};
9use serde::{Deserialize, Serialize};
10use web_time::{SystemTime, UNIX_EPOCH};
11
12/// Internal raw deserialization struct — uses serde_json::Value for fields
13/// that participate in DynamoDB's multi-field constraint validation.
14#[derive(Debug, Default, Deserialize)]
15struct RawRequest {
16    #[serde(rename = "TableName", default)]
17    table_name: Option<String>,
18    #[serde(rename = "KeySchema", default)]
19    key_schema: Option<serde_json::Value>,
20    #[serde(rename = "AttributeDefinitions", default)]
21    attribute_definitions: Option<serde_json::Value>,
22    #[serde(rename = "GlobalSecondaryIndexes", default)]
23    global_secondary_indexes: Option<serde_json::Value>,
24    #[serde(rename = "LocalSecondaryIndexes", default)]
25    local_secondary_indexes: Option<serde_json::Value>,
26    #[serde(rename = "BillingMode", default)]
27    billing_mode: Option<String>,
28    #[serde(rename = "ProvisionedThroughput", default)]
29    provisioned_throughput: Option<serde_json::Value>,
30    #[serde(rename = "StreamSpecification", default)]
31    stream_specification: Option<StreamSpecification>,
32    #[serde(rename = "SSESpecification", default)]
33    sse_specification: Option<crate::types::SseSpecification>,
34    #[serde(rename = "TableClass", default)]
35    table_class: Option<String>,
36    #[serde(rename = "Tags", default)]
37    tags: Option<Vec<crate::types::Tag>>,
38    #[serde(rename = "DeletionProtectionEnabled", default)]
39    deletion_protection_enabled: Option<bool>,
40    #[serde(rename = "OnDemandThroughput", default)]
41    on_demand_throughput: Option<crate::types::OnDemandThroughput>,
42}
43
44/// Public request type — fully validated, typed fields.
45/// Can be constructed directly (programmatic use) or deserialized from JSON.
46#[derive(Debug, Default)]
47pub struct CreateTableRequest {
48    pub table_name: String,
49    pub key_schema: Vec<KeySchemaElement>,
50    pub attribute_definitions: Vec<AttributeDefinition>,
51    pub global_secondary_indexes: Option<Vec<GlobalSecondaryIndex>>,
52    pub local_secondary_indexes: Option<Vec<LocalSecondaryIndex>>,
53    pub billing_mode: Option<String>,
54    pub provisioned_throughput: Option<ProvisionedThroughput>,
55    pub stream_specification: Option<StreamSpecification>,
56    pub sse_specification: Option<crate::types::SseSpecification>,
57    pub table_class: Option<String>,
58    pub tags: Option<Vec<crate::types::Tag>>,
59    pub deletion_protection_enabled: Option<bool>,
60    pub on_demand_throughput: Option<crate::types::OnDemandThroughput>,
61}
62
63/// Custom Deserialize that does loose JSON parsing first, validates, then builds typed fields.
64/// Validation errors use "VALIDATION:" prefix so server.rs converts them to ValidationException.
65impl<'de> serde::Deserialize<'de> for CreateTableRequest {
66    fn deserialize<D: serde::Deserializer<'de>>(
67        deserializer: D,
68    ) -> std::result::Result<Self, D::Error> {
69        let raw = RawRequest::deserialize(deserializer)?;
70        match validate_raw_and_build(raw) {
71            Ok(req) => Ok(req),
72            Err(msg) => Err(serde::de::Error::custom(format!("VALIDATION:{}", msg))),
73        }
74    }
75}
76
77#[derive(Debug, Default, Deserialize)]
78pub struct StreamSpecification {
79    #[serde(rename = "StreamEnabled", alias = "stream_enabled")]
80    pub stream_enabled: bool,
81    #[serde(rename = "StreamViewType", alias = "stream_view_type", default)]
82    pub stream_view_type: Option<String>,
83}
84
85#[derive(Debug, Default, Serialize)]
86pub struct CreateTableResponse {
87    #[serde(rename = "TableDescription")]
88    pub table_description: TableDescription,
89}
90
91pub async fn execute<S: StorageBackend>(
92    storage: &S,
93    mut request: CreateTableRequest,
94) -> Result<CreateTableResponse> {
95    // An OnDemandThroughput object with no members is equivalent to omitting
96    // it: real DynamoDB accepts it at creation and stores nothing (eu-west-2
97    // capture, 2026-07-24), so normalise before any gate looks at it.
98    if request.on_demand_throughput.as_ref().is_some_and(|odt| {
99        odt.max_read_request_units.is_none() && odt.max_write_request_units.is_none()
100    }) {
101        request.on_demand_throughput = None;
102    }
103
104    // Structural validation (runs for both programmatic and JSON paths)
105    validate_typed_request(&request)?;
106
107    if let Some(ref bm) = request.billing_mode {
108        if bm != "PROVISIONED" && bm != "PAY_PER_REQUEST" {
109            return Err(DynoxideError::ValidationException(format!(
110                "1 validation error detected: Value '{bm}' at 'billingMode' failed to satisfy \
111                 constraint: Member must satisfy enum value set: \
112                 [PROVISIONED, PAY_PER_REQUEST]"
113            )));
114        }
115    }
116
117    if let Some(ref tc) = request.table_class {
118        if tc != "STANDARD" && tc != "STANDARD_INFREQUENT_ACCESS" {
119            return Err(DynoxideError::ValidationException(format!(
120                "1 validation error detected: Value '{tc}' at 'tableClass' failed to satisfy \
121                 constraint: Member must satisfy enum value set: \
122                 [STANDARD, STANDARD_INFREQUENT_ACCESS]"
123            )));
124        }
125    }
126
127    // OnDemandThroughput is only valid alongside PAY_PER_REQUEST billing, and
128    // its members must be at least 1 at creation (-1 is an UpdateTable-only
129    // removal marker). Both messages captured from real DynamoDB (eu-west-2,
130    // 2026-07-24); the gate names the first present member, read checked
131    // first, and the create wording drops "the" and carries a full stop.
132    if let Some(ref odt) = request.on_demand_throughput {
133        let members = [
134            ("MaxReadRequestUnits", odt.max_read_request_units),
135            ("MaxWriteRequestUnits", odt.max_write_request_units),
136        ];
137        if let Some((member, _)) = members.iter().find(|(_, v)| v.is_some()) {
138            let billing_mode_str = request.billing_mode.as_deref().unwrap_or("PROVISIONED");
139            if billing_mode_str == "PROVISIONED" {
140                return Err(DynoxideError::ValidationException(format!(
141                    "One or more parameter values were invalid: {member} for \
142                     OnDemandThroughput cannot be specified when table BillingMode \
143                     is PROVISIONED."
144                )));
145            }
146        }
147        for (member, value) in members {
148            if value.is_some_and(|v| v < 1) {
149                return Err(DynoxideError::ValidationException(format!(
150                    "One or more parameter values were invalid: Requested {member} for \
151                     OnDemandThroughput for table is outside of valid range"
152                )));
153            }
154        }
155    }
156
157    if storage.table_exists(&request.table_name).await? {
158        return Err(DynoxideError::ResourceInUseException(format!(
159            "Table already exists: {}",
160            request.table_name
161        )));
162    }
163
164    let now = SystemTime::now()
165        .duration_since(UNIX_EPOCH)
166        .unwrap_or_default()
167        .as_secs() as i64;
168
169    let key_schema_json = serde_json::to_string(&request.key_schema)
170        .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
171    let attr_defs_json = serde_json::to_string(&request.attribute_definitions)
172        .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
173    let gsi_json = request
174        .global_secondary_indexes
175        .as_ref()
176        .map(serde_json::to_string)
177        .transpose()
178        .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
179    let lsi_json = request
180        .local_secondary_indexes
181        .as_ref()
182        .map(serde_json::to_string)
183        .transpose()
184        .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
185    let pt_json = request
186        .provisioned_throughput
187        .as_ref()
188        .map(serde_json::to_string)
189        .transpose()
190        .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
191    // Normalise the SSE spec so a DescribeTable round-trip matches AWS: when
192    // encryption is enabled without an explicit type/key, AWS reports SSEType=KMS
193    // and a KMS key ARN (the AWS-managed `aws/dynamodb` key). Persisting the
194    // synthesised key id keeps the reported ARN stable across DescribeTable calls.
195    let normalized_sse = request.sse_specification.as_ref().map(|spec| {
196        if spec.enabled == Some(true) {
197            crate::types::SseSpecification {
198                enabled: Some(true),
199                sse_type: spec.sse_type.clone().or_else(|| Some("KMS".to_string())),
200                kms_master_key_id: spec.kms_master_key_id.clone().or_else(|| {
201                    Some(crate::streams::kms_key_arn(
202                        &uuid::Uuid::new_v4().to_string(),
203                    ))
204                }),
205            }
206        } else {
207            spec.clone()
208        }
209    });
210    let sse_json = normalized_sse
211        .as_ref()
212        .map(serde_json::to_string)
213        .transpose()
214        .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
215    let on_demand_json = request
216        .on_demand_throughput
217        .as_ref()
218        .map(serde_json::to_string)
219        .transpose()
220        .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
221    let deletion_protection = request.deletion_protection_enabled.unwrap_or(false);
222
223    let billing_mode_str = request.billing_mode.as_deref().unwrap_or("PROVISIONED");
224    storage
225        .insert_table_metadata(&crate::storage::CreateTableMetadata {
226            table_name: &request.table_name,
227            key_schema: &key_schema_json,
228            attribute_definitions: &attr_defs_json,
229            gsi_definitions: gsi_json.as_deref(),
230            lsi_definitions: lsi_json.as_deref(),
231            provisioned_throughput: pt_json.as_deref(),
232            created_at: now,
233            sse_specification: sse_json.as_deref(),
234            table_class: request.table_class.as_deref(),
235            deletion_protection_enabled: deletion_protection,
236            billing_mode: Some(billing_mode_str),
237            on_demand_throughput: on_demand_json.as_deref(),
238        })
239        .await?;
240
241    storage.create_data_table(&request.table_name).await?;
242
243    if let Some(ref gsis) = request.global_secondary_indexes {
244        for gsi in gsis {
245            storage
246                .create_gsi_table(&request.table_name, &gsi.index_name)
247                .await?;
248        }
249    }
250
251    if let Some(ref lsis) = request.local_secondary_indexes {
252        for lsi in lsis {
253            storage
254                .create_lsi_table(&request.table_name, &lsi.index_name)
255                .await?;
256        }
257    }
258
259    if let Some(ref spec) = request.stream_specification {
260        if spec.stream_enabled {
261            let view_type = spec
262                .stream_view_type
263                .as_deref()
264                .unwrap_or("NEW_AND_OLD_IMAGES");
265            let label = streams::generate_stream_label(storage.clock());
266            storage
267                .enable_stream(&request.table_name, view_type, &label)
268                .await?;
269        }
270    }
271
272    if let Some(ref tags) = request.tags {
273        if !tags.is_empty() {
274            storage.set_tags(&request.table_name, tags).await?;
275        }
276    }
277
278    let meta = storage
279        .get_table_metadata(&request.table_name)
280        .await?
281        .ok_or_else(|| {
282            DynoxideError::InternalServerError("Table metadata not found after creation".into())
283        })?;
284
285    let mut desc = build_table_description(&meta, Some(0), Some(0));
286    // CreateTable response shows CREATING status (table is usable immediately
287    // but DynamoDB API contract says newly-created tables start as CREATING)
288    desc.table_status = "CREATING".to_string();
289
290    // Override billing mode fields based on the actual request
291    let billing_mode_str = request.billing_mode.as_deref().unwrap_or("PROVISIONED");
292    if billing_mode_str == "PROVISIONED" {
293        desc.billing_mode_summary = None;
294        desc.table_throughput_mode_summary = None;
295    } else if billing_mode_str == "PAY_PER_REQUEST" {
296        desc.billing_mode_summary = Some(crate::actions::BillingModeSummary {
297            billing_mode: "PAY_PER_REQUEST".to_string(),
298            last_update_to_pay_per_request_date_time: None,
299        });
300        desc.table_throughput_mode_summary = Some(crate::actions::TableThroughputModeSummary {
301            table_throughput_mode: "PAY_PER_REQUEST".to_string(),
302            last_update_to_pay_per_request_date_time: None,
303        });
304        // Ensure provisioned throughput shows zeros for PAY_PER_REQUEST
305        desc.provisioned_throughput = Some(crate::actions::TableProvisionedThroughputDescription {
306            read_capacity_units: 0,
307            write_capacity_units: 0,
308            number_of_decreases_today: 0,
309            last_increase_date_time: None,
310            last_decrease_date_time: None,
311        });
312    }
313
314    // Set all GSI statuses to CREATING for newly created tables
315    if let Some(ref mut gsis) = desc.global_secondary_indexes {
316        for gsi in gsis {
317            gsi.index_status = "CREATING".to_string();
318        }
319    }
320
321    // Remove DeletionProtectionEnabled from response if not explicitly set
322    // (DynamoDB doesn't include it in basic CreateTable response)
323    if request.deletion_protection_enabled.is_none() {
324        desc.deletion_protection_enabled = None;
325    }
326
327    Ok(CreateTableResponse {
328        table_description: desc,
329    })
330}
331
332/// Convert a String error to DynoxideError::ValidationException.
333fn ve(msg: String) -> DynoxideError {
334    DynoxideError::ValidationException(msg)
335}
336
337/// Validate a programmatically-constructed request (used when not deserialised from JSON).
338///
339/// The validation order matches DynamoDB's actual behaviour (as verified by the Dynalite
340/// conformance suite):
341///
342/// 1. Table name (missing, length, pattern)
343/// 2. BillingMode + ProvisionedThroughput consistency
344/// 3. ProvisionedThroughput out-of-bounds
345/// 4. Missing ProvisionedThroughput (default PROVISIONED billing)
346/// 5. Key attribute definition checks ("Invalid KeySchema" / detailed missing-attr message)
347/// 6. Key schema structure (duplicate names, wrong types)
348/// 7. Empty LSI/GSI lists
349/// 8. LSI/GSI structural validation (key schema, projections, duplicates, limits)
350/// 9. Cross-index duplicate names
351/// 10. Attribute definition count mismatch
352/// 11. StreamSpecification consistency (a disabled stream must not set a view type)
353fn validate_typed_request(request: &CreateTableRequest) -> Result<()> {
354    if request.table_name.is_empty() {
355        return Err(DynoxideError::ValidationException(
356            "The parameter 'TableName' is required but was not present in the request".to_string(),
357        ));
358    }
359    if request.table_name.len() < 3 || request.table_name.len() > 255 {
360        return Err(DynoxideError::ValidationException(
361            "TableName must be at least 3 characters long and at most 255 characters long"
362                .to_string(),
363        ));
364    }
365
366    // Table name pattern
367    if !request
368        .table_name
369        .chars()
370        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
371    {
372        return Err(DynoxideError::ValidationException(format!(
373            "1 validation error detected: Value '{}' at 'tableName' failed to satisfy constraint: \
374             Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+",
375            request.table_name
376        )));
377    }
378
379    // BillingMode + ProvisionedThroughput consistency
380    let billing_mode_str = request.billing_mode.as_deref().unwrap_or("PROVISIONED");
381    if billing_mode_str == "PAY_PER_REQUEST" && request.provisioned_throughput.is_some() {
382        return Err(DynoxideError::ValidationException(
383            "One or more parameter values were invalid: Neither ReadCapacityUnits nor \
384             WriteCapacityUnits can be specified when BillingMode is PAY_PER_REQUEST"
385                .to_string(),
386        ));
387    }
388
389    // ProvisionedThroughput out-of-bounds
390    if let Some(ref pt) = request.provisioned_throughput {
391        const MAX_THROUGHPUT: i64 = 1_000_000_000_000;
392        let rcu = pt.read_capacity_units.unwrap_or(0);
393        let wcu = pt.write_capacity_units.unwrap_or(0);
394        if rcu > MAX_THROUGHPUT {
395            return Err(DynoxideError::ValidationException(format!(
396                "Given value {} for ReadCapacityUnits is out of bounds",
397                rcu
398            )));
399        }
400        if wcu > MAX_THROUGHPUT {
401            return Err(DynoxideError::ValidationException(format!(
402                "Given value {} for WriteCapacityUnits is out of bounds",
403                wcu
404            )));
405        }
406    }
407
408    // Missing ProvisionedThroughput when billing mode is explicitly PROVISIONED.
409    // For the programmatic API, when BillingMode is not specified we default to
410    // PAY_PER_REQUEST for convenience. The HTTP/JSON path (validate_raw_and_build)
411    // applies the stricter DynamoDB default of PROVISIONED.
412    if request.billing_mode.is_some()
413        && billing_mode_str == "PROVISIONED"
414        && request.provisioned_throughput.is_none()
415    {
416        return Err(DynoxideError::ValidationException(
417            "One or more parameter values were invalid: ReadCapacityUnits and \
418             WriteCapacityUnits must both be specified when BillingMode is PROVISIONED"
419                .to_string(),
420        ));
421    }
422
423    // Key attribute definition checks (before key schema structure)
424    validate_key_attrs_in_defs(&request.key_schema, &request.attribute_definitions).map_err(ve)?;
425
426    // Key schema structure
427    validate_key_schema_structure(&request.key_schema).map_err(ve)?;
428
429    // Empty LSI/GSI lists (before structural validation and attr count)
430    if let Some(ref lsis) = request.local_secondary_indexes {
431        if lsis.is_empty() {
432            return Err(ve(
433                "One or more parameter values were invalid: List of LocalSecondaryIndexes is empty"
434                    .to_string(),
435            ));
436        }
437    }
438    if let Some(ref gsis) = request.global_secondary_indexes {
439        if gsis.is_empty() {
440            return Err(ve(
441                "One or more parameter values were invalid: List of GlobalSecondaryIndexes is empty"
442                    .to_string(),
443            ));
444        }
445    }
446
447    // LSI structural validation
448    if let Some(ref lsis) = request.local_secondary_indexes {
449        validate_lsi_list(lsis, &request.key_schema, &request.attribute_definitions).map_err(ve)?;
450    }
451
452    // GSI structural validation
453    if let Some(ref gsis) = request.global_secondary_indexes {
454        let bm = request.billing_mode.as_deref().unwrap_or("PROVISIONED");
455        validate_gsi_list(gsis, &request.attribute_definitions, bm).map_err(ve)?;
456    }
457
458    // Cross-index duplicate names (checked before attr def count)
459    check_cross_index_duplicates(
460        &request.local_secondary_indexes,
461        &request.global_secondary_indexes,
462    )
463    .map_err(ve)?;
464
465    // Attribute definition count (last of the key/index checks)
466    validate_attr_def_count(
467        &request.key_schema,
468        &request.attribute_definitions,
469        &request.local_secondary_indexes,
470        &request.global_secondary_indexes,
471    )
472    .map_err(ve)?;
473
474    // StreamSpecification consistency: a disabled stream must not carry a view
475    // type. Real DynamoDB rejects the combination, because a view type only has
476    // meaning when the stream is enabled.
477    if let Some(ref spec) = request.stream_specification {
478        if !spec.stream_enabled && spec.stream_view_type.is_some() {
479            return Err(DynoxideError::ValidationException(
480                "One or more parameter values were invalid: Table is being created with a stream \
481                 disabled, UpdateViewType should not be specified"
482                    .to_string(),
483            ));
484        }
485    }
486
487    Ok(())
488}
489
490fn check_cross_index_duplicates(
491    lsis: &Option<Vec<LocalSecondaryIndex>>,
492    gsis: &Option<Vec<GlobalSecondaryIndex>>,
493) -> std::result::Result<(), String> {
494    if let (Some(lsis), Some(gsis)) = (lsis, gsis) {
495        let mut all_names = std::collections::HashSet::new();
496        for lsi in lsis {
497            all_names.insert(&lsi.index_name);
498        }
499        for gsi in gsis {
500            if !all_names.insert(&gsi.index_name) {
501                return Err(format!(
502                    "One or more parameter values were invalid: Duplicate index name: {}",
503                    gsi.index_name
504                ));
505            }
506        }
507    }
508    Ok(())
509}
510
511// ---- Raw JSON validation (for deserialization path) ----
512
513fn validate_raw_and_build(raw: RawRequest) -> std::result::Result<CreateTableRequest, String> {
514    // Missing TableName is a different error format from invalid TableName
515    if raw.table_name.is_none() {
516        return Err(
517            "The parameter 'TableName' is required but was not present in the request".to_string(),
518        );
519    }
520
521    // Use the shared constraint error collector for table name validation.
522    // This produces the correct multi-field constraint format for empty,
523    // too-short, too-long, or invalid-pattern table names.
524    let name_errors = crate::validation::table_name_constraint_errors(
525        raw.table_name.as_deref(),
526        crate::validation::TableNameContext::CreateTable,
527    );
528    if !name_errors.is_empty() {
529        let msg = format!(
530            "{} validation error{} detected: {}",
531            name_errors.len(),
532            if name_errors.len() > 1 { "s" } else { "" },
533            name_errors.join("; ")
534        );
535        return Err(msg);
536    }
537    let table_name = raw.table_name.unwrap();
538
539    let mut errors = Vec::new();
540
541    if let Some(ref bm) = raw.billing_mode {
542        if bm != "PROVISIONED" && bm != "PAY_PER_REQUEST" {
543            errors.push(format!(
544                "Value '{}' at 'billingMode' failed to satisfy constraint: \
545                 Member must satisfy enum value set: [PROVISIONED, PAY_PER_REQUEST]",
546                bm
547            ));
548        }
549    }
550
551    collect_pt_errors(&raw.provisioned_throughput, &mut errors);
552    collect_ks_errors(&raw.key_schema, &mut errors);
553    collect_ad_errors(&raw.attribute_definitions, &mut errors);
554    collect_lsi_errors(&raw.local_secondary_indexes, &mut errors);
555    collect_gsi_errors(&raw.global_secondary_indexes, &mut errors);
556
557    // DynamoDB caps multi-field constraint errors at 10
558    errors.truncate(10);
559
560    if !errors.is_empty() {
561        let prefix = format!(
562            "{} validation error{} detected: ",
563            errors.len(),
564            if errors.len() == 1 { "" } else { "s" }
565        );
566        return Err(format!("{}{}", prefix, errors.join("; ")));
567    }
568
569    // BillingMode + ProvisionedThroughput consistency (HTTP path only)
570    let billing_mode_str = raw.billing_mode.as_deref().unwrap_or("PROVISIONED");
571    if billing_mode_str == "PAY_PER_REQUEST" && raw.provisioned_throughput.is_some() {
572        return Err(
573            "One or more parameter values were invalid: Neither ReadCapacityUnits nor \
574             WriteCapacityUnits can be specified when BillingMode is PAY_PER_REQUEST"
575                .to_string(),
576        );
577    }
578
579    // ProvisionedThroughput out-of-bounds (after multi-field but before struct checks)
580    if let Some(ref pt) = raw.provisioned_throughput {
581        if let Some(obj) = pt.as_object() {
582            let rcu = obj
583                .get("ReadCapacityUnits")
584                .and_then(|v| v.as_i64())
585                .unwrap_or(0);
586            let wcu = obj
587                .get("WriteCapacityUnits")
588                .and_then(|v| v.as_i64())
589                .unwrap_or(0);
590            const MAX_THROUGHPUT: i64 = 1_000_000_000_000;
591            if rcu > MAX_THROUGHPUT {
592                return Err(format!(
593                    "Given value {} for ReadCapacityUnits is out of bounds",
594                    rcu
595                ));
596            }
597            if wcu > MAX_THROUGHPUT {
598                return Err(format!(
599                    "Given value {} for WriteCapacityUnits is out of bounds",
600                    wcu
601                ));
602            }
603        }
604    }
605
606    // Missing ProvisionedThroughput when BillingMode is explicitly PROVISIONED.
607    if raw.billing_mode.as_deref() == Some("PROVISIONED") && raw.provisioned_throughput.is_none() {
608        return Err(
609            "One or more parameter values were invalid: ReadCapacityUnits and \
610             WriteCapacityUnits must both be specified when BillingMode is PROVISIONED"
611                .to_string(),
612        );
613    }
614
615    // Parse typed structures
616    let key_schema: Vec<KeySchemaElement> = raw
617        .key_schema
618        .as_ref()
619        .map(|v| serde_json::from_value(v.clone()))
620        .transpose()
621        .map_err(|e| e.to_string())?
622        .unwrap_or_default();
623    let attribute_definitions: Vec<AttributeDefinition> = raw
624        .attribute_definitions
625        .as_ref()
626        .map(|v| serde_json::from_value(v.clone()))
627        .transpose()
628        .map_err(|e| e.to_string())?
629        .unwrap_or_default();
630    let provisioned_throughput: Option<ProvisionedThroughput> = raw
631        .provisioned_throughput
632        .as_ref()
633        .map(|v| serde_json::from_value(v.clone()))
634        .transpose()
635        .map_err(|e| e.to_string())?;
636    let global_secondary_indexes: Option<Vec<GlobalSecondaryIndex>> = raw
637        .global_secondary_indexes
638        .as_ref()
639        .map(|v| serde_json::from_value(v.clone()))
640        .transpose()
641        .map_err(|e| e.to_string())?;
642    let local_secondary_indexes: Option<Vec<LocalSecondaryIndex>> = raw
643        .local_secondary_indexes
644        .as_ref()
645        .map(|v| serde_json::from_value(v.clone()))
646        .transpose()
647        .map_err(|e| e.to_string())?;
648
649    Ok(CreateTableRequest {
650        table_name,
651        key_schema,
652        attribute_definitions,
653        global_secondary_indexes,
654        local_secondary_indexes,
655        billing_mode: raw.billing_mode,
656        provisioned_throughput,
657        stream_specification: raw.stream_specification,
658        sse_specification: raw.sse_specification,
659        table_class: raw.table_class,
660        tags: raw.tags,
661        deletion_protection_enabled: raw.deletion_protection_enabled,
662        on_demand_throughput: raw.on_demand_throughput,
663    })
664}
665
666// ---- Multi-field constraint error collectors ----
667
668fn collect_pt_errors(pt_val: &Option<serde_json::Value>, errors: &mut Vec<String>) {
669    if let Some(v) = pt_val {
670        if let Some(obj) = v.as_object() {
671            let wcu = obj.get("WriteCapacityUnits");
672            let rcu = obj.get("ReadCapacityUnits");
673            if wcu.is_none() || wcu == Some(&serde_json::Value::Null) {
674                errors.push("Value null at 'provisionedThroughput.writeCapacityUnits' failed to satisfy constraint: Member must not be null".to_string());
675            } else if let Some(w) = wcu.and_then(|v| v.as_i64()) {
676                if w < 1 {
677                    errors.push(format!("Value '{}' at 'provisionedThroughput.writeCapacityUnits' failed to satisfy constraint: Member must have value greater than or equal to 1", w));
678                }
679            }
680            if rcu.is_none() || rcu == Some(&serde_json::Value::Null) {
681                errors.push("Value null at 'provisionedThroughput.readCapacityUnits' failed to satisfy constraint: Member must not be null".to_string());
682            } else if let Some(r) = rcu.and_then(|v| v.as_i64()) {
683                if r < 1 {
684                    errors.push(format!("Value '{}' at 'provisionedThroughput.readCapacityUnits' failed to satisfy constraint: Member must have value greater than or equal to 1", r));
685                }
686            }
687        }
688    }
689}
690
691fn collect_ks_errors(ks_val: &Option<serde_json::Value>, errors: &mut Vec<String>) {
692    match ks_val {
693        None => {
694            errors.push(
695                "Value null at 'keySchema' failed to satisfy constraint: Member must not be null"
696                    .to_string(),
697            );
698        }
699        Some(v) => {
700            if let Some(arr) = v.as_array() {
701                if arr.is_empty() {
702                    errors.push("Value '[]' at 'keySchema' failed to satisfy constraint: Member must have length greater than or equal to 1".to_string());
703                } else if arr.len() > 2 {
704                    let dump = render_key_schema_java_toString(arr);
705                    errors.push(format!("Value '{}' at 'keySchema' failed to satisfy constraint: Member must have length less than or equal to 2", dump));
706                }
707                for (i, elem) in arr.iter().enumerate().take(10) {
708                    collect_ks_elem_errors(elem, i + 1, errors);
709                }
710            }
711        }
712    }
713}
714
715fn collect_ks_elem_errors(elem: &serde_json::Value, idx: usize, errors: &mut Vec<String>) {
716    if let Some(obj) = elem.as_object() {
717        if !obj.contains_key("AttributeName")
718            || obj.get("AttributeName") == Some(&serde_json::Value::Null)
719        {
720            errors.push(format!("Value null at 'keySchema.{}.member.attributeName' failed to satisfy constraint: Member must not be null", idx));
721        }
722        let kt = obj.get("KeyType");
723        if kt.is_none() || kt == Some(&serde_json::Value::Null) {
724            errors.push(format!("Value null at 'keySchema.{}.member.keyType' failed to satisfy constraint: Member must not be null", idx));
725        } else if let Some(s) = kt.and_then(|v| v.as_str()) {
726            if s != "HASH" && s != "RANGE" {
727                errors.push(format!("Value '{}' at 'keySchema.{}.member.keyType' failed to satisfy constraint: Member must satisfy enum value set: [HASH, RANGE]", s, idx));
728            }
729        }
730    }
731}
732
733/// Render a KeySchema array the way AWS does in validation messages: Java's
734/// default toString() shape over the SDK's KeySchemaElement model, e.g.
735/// `[KeySchemaElement(attributeName=pk, keyType=HASH), KeySchemaElement(attributeName=sk, keyType=RANGE)]`.
736/// Missing attribute fields render as the empty string, matching what AWS
737/// emits when the upstream object hasn't been populated.
738#[allow(non_snake_case)]
739fn render_key_schema_java_toString(arr: &[serde_json::Value]) -> String {
740    let parts: Vec<String> = arr
741        .iter()
742        .map(|elem| {
743            let an = elem
744                .get("AttributeName")
745                .and_then(|v| v.as_str())
746                .unwrap_or("");
747            let kt = elem.get("KeyType").and_then(|v| v.as_str()).unwrap_or("");
748            format!("KeySchemaElement(attributeName={an}, keyType={kt})")
749        })
750        .collect();
751    format!("[{}]", parts.join(", "))
752}
753
754fn collect_ad_errors(ad_val: &Option<serde_json::Value>, errors: &mut Vec<String>) {
755    match ad_val {
756        None => {
757            errors.push("Value null at 'attributeDefinitions' failed to satisfy constraint: Member must not be null".to_string());
758        }
759        Some(v) => {
760            if let Some(arr) = v.as_array() {
761                for (i, elem) in arr.iter().enumerate() {
762                    if let Some(obj) = elem.as_object() {
763                        if !obj.contains_key("AttributeName")
764                            || obj.get("AttributeName") == Some(&serde_json::Value::Null)
765                        {
766                            errors.push(format!("Value null at 'attributeDefinitions.{}.member.attributeName' failed to satisfy constraint: Member must not be null", i + 1));
767                        }
768                        let at = obj.get("AttributeType");
769                        if at.is_none() || at == Some(&serde_json::Value::Null) {
770                            errors.push(format!("Value null at 'attributeDefinitions.{}.member.attributeType' failed to satisfy constraint: Member must not be null", i + 1));
771                        } else if let Some(s) = at.and_then(|v| v.as_str()) {
772                            if s != "S" && s != "N" && s != "B" {
773                                errors.push(format!("Value '{}' at 'attributeDefinitions.{}.member.attributeType' failed to satisfy constraint: Member must satisfy enum value set: [B, N, S]", s, i + 1));
774                            }
775                        }
776                    }
777                }
778            }
779        }
780    }
781}
782
783fn collect_lsi_errors(lsi_val: &Option<serde_json::Value>, errors: &mut Vec<String>) {
784    if let Some(v) = lsi_val {
785        if let Some(arr) = v.as_array() {
786            for (i, elem) in arr.iter().enumerate().take(10) {
787                if let Some(obj) = elem.as_object() {
788                    // Order: indexName, keySchema, projection
789                    if !obj.contains_key("IndexName")
790                        || obj.get("IndexName") == Some(&serde_json::Value::Null)
791                    {
792                        errors.push(format!("Value null at 'localSecondaryIndexes.{}.member.indexName' failed to satisfy constraint: Member must not be null", i + 1));
793                    } else if let Some(name) = obj.get("IndexName").and_then(|v| v.as_str()) {
794                        collect_idx_name_errors(name, "localSecondaryIndexes", i + 1, errors);
795                    }
796                    if !obj.contains_key("KeySchema")
797                        || obj.get("KeySchema") == Some(&serde_json::Value::Null)
798                    {
799                        errors.push(format!("Value null at 'localSecondaryIndexes.{}.member.keySchema' failed to satisfy constraint: Member must not be null", i + 1));
800                    } else if let Some(ks) = obj.get("KeySchema").and_then(|v| v.as_array()) {
801                        if ks.is_empty() {
802                            errors.push(format!("Value '[]' at 'localSecondaryIndexes.{}.member.keySchema' failed to satisfy constraint: Member must have length greater than or equal to 1", i + 1));
803                        }
804                    }
805                    if !obj.contains_key("Projection")
806                        || obj.get("Projection") == Some(&serde_json::Value::Null)
807                    {
808                        errors.push(format!("Value null at 'localSecondaryIndexes.{}.member.projection' failed to satisfy constraint: Member must not be null", i + 1));
809                    } else if let Some(p) = obj.get("Projection").and_then(|v| v.as_object()) {
810                        collect_proj_errors(p, &format!("localSecondaryIndexes.{}", i + 1), errors);
811                    }
812                }
813            }
814        }
815    }
816}
817
818fn collect_gsi_errors(gsi_val: &Option<serde_json::Value>, errors: &mut Vec<String>) {
819    if let Some(v) = gsi_val {
820        if let Some(arr) = v.as_array() {
821            for (i, elem) in arr.iter().enumerate().take(10) {
822                if let Some(obj) = elem.as_object() {
823                    // Order for GSI: keySchema, projection, indexName
824                    if !obj.contains_key("KeySchema")
825                        || obj.get("KeySchema") == Some(&serde_json::Value::Null)
826                    {
827                        errors.push(format!("Value null at 'globalSecondaryIndexes.{}.member.keySchema' failed to satisfy constraint: Member must not be null", i + 1));
828                    } else if let Some(ks) = obj.get("KeySchema").and_then(|v| v.as_array()) {
829                        if ks.is_empty() {
830                            errors.push(format!("Value '[]' at 'globalSecondaryIndexes.{}.member.keySchema' failed to satisfy constraint: Member must have length greater than or equal to 1", i + 1));
831                        }
832                    }
833                    if !obj.contains_key("Projection")
834                        || obj.get("Projection") == Some(&serde_json::Value::Null)
835                    {
836                        errors.push(format!("Value null at 'globalSecondaryIndexes.{}.member.projection' failed to satisfy constraint: Member must not be null", i + 1));
837                    } else if let Some(p) = obj.get("Projection").and_then(|v| v.as_object()) {
838                        collect_proj_errors(
839                            p,
840                            &format!("globalSecondaryIndexes.{}", i + 1),
841                            errors,
842                        );
843                    }
844                    if !obj.contains_key("IndexName")
845                        || obj.get("IndexName") == Some(&serde_json::Value::Null)
846                    {
847                        errors.push(format!("Value null at 'globalSecondaryIndexes.{}.member.indexName' failed to satisfy constraint: Member must not be null", i + 1));
848                    } else if let Some(name) = obj.get("IndexName").and_then(|v| v.as_str()) {
849                        collect_idx_name_errors(name, "globalSecondaryIndexes", i + 1, errors);
850                    }
851                    // GSI ProvisionedThroughput
852                    if let Some(pt) = obj.get("ProvisionedThroughput").and_then(|v| v.as_object()) {
853                        let wcu = pt.get("WriteCapacityUnits");
854                        let rcu = pt.get("ReadCapacityUnits");
855                        if let Some(w) = wcu.and_then(|v| v.as_i64()) {
856                            if w < 1 {
857                                errors.push(format!("Value '{}' at 'globalSecondaryIndexes.{}.member.provisionedThroughput.writeCapacityUnits' failed to satisfy constraint: Member must have value greater than or equal to 1", w, i + 1));
858                            }
859                        } else if wcu.is_none() || wcu == Some(&serde_json::Value::Null) {
860                            errors.push(format!("Value null at 'globalSecondaryIndexes.{}.member.provisionedThroughput.writeCapacityUnits' failed to satisfy constraint: Member must not be null", i + 1));
861                        }
862                        if let Some(r) = rcu.and_then(|v| v.as_i64()) {
863                            if r < 1 {
864                                errors.push(format!("Value '{}' at 'globalSecondaryIndexes.{}.member.provisionedThroughput.readCapacityUnits' failed to satisfy constraint: Member must have value greater than or equal to 1", r, i + 1));
865                            }
866                        } else if rcu.is_none() || rcu == Some(&serde_json::Value::Null) {
867                            errors.push(format!("Value null at 'globalSecondaryIndexes.{}.member.provisionedThroughput.readCapacityUnits' failed to satisfy constraint: Member must not be null", i + 1));
868                        }
869                    }
870                }
871            }
872        }
873    }
874}
875
876fn collect_idx_name_errors(name: &str, prefix: &str, idx: usize, errors: &mut Vec<String>) {
877    if !name
878        .chars()
879        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
880    {
881        errors.push(format!("Value '{}' at '{}.{}.member.indexName' failed to satisfy constraint: Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+", name, prefix, idx));
882    }
883    if name.len() < 3 {
884        errors.push(format!("Value '{}' at '{}.{}.member.indexName' failed to satisfy constraint: Member must have length greater than or equal to 3", name, prefix, idx));
885    }
886    if name.len() > 255 {
887        errors.push(format!("Value '{}' at '{}.{}.member.indexName' failed to satisfy constraint: Member must have length less than or equal to 255", name, prefix, idx));
888    }
889}
890
891fn collect_proj_errors(
892    proj: &serde_json::Map<String, serde_json::Value>,
893    prefix: &str,
894    errors: &mut Vec<String>,
895) {
896    if let Some(pt) = proj.get("ProjectionType") {
897        if let Some(s) = pt.as_str() {
898            if s != "ALL" && s != "KEYS_ONLY" && s != "INCLUDE" {
899                errors.push(format!("Value '{}' at '{}.member.projection.projectionType' failed to satisfy constraint: Member must satisfy enum value set: [ALL, INCLUDE, KEYS_ONLY]", s, prefix));
900            }
901        }
902    }
903    if let Some(nka) = proj.get("NonKeyAttributes") {
904        if let Some(arr) = nka.as_array() {
905            if arr.is_empty() {
906                errors.push(format!("Value '[]' at '{}.member.projection.nonKeyAttributes' failed to satisfy constraint: Member must have length greater than or equal to 1", prefix));
907            }
908        }
909    }
910}
911
912// ---- Structural validation helpers ----
913
914fn validate_key_schema_structure(ks: &[KeySchemaElement]) -> std::result::Result<(), String> {
915    if ks.is_empty() {
916        return Err("1 validation error detected: Value null at 'keySchema' failed to satisfy constraint: Member must have length less than or equal to 2".to_string());
917    }
918    if ks[0].key_type != KeyType::HASH {
919        return Err(
920            "Invalid KeySchema: The first KeySchemaElement is not a HASH key type".to_string(),
921        );
922    }
923    if ks.len() == 2 && ks[0].attribute_name == ks[1].attribute_name {
924        return Err(
925            "Both the Hash Key and the Range Key element in the KeySchema have the same name"
926                .to_string(),
927        );
928    }
929    if ks.len() == 2 && ks[1].key_type != KeyType::RANGE {
930        return Err(
931            "Invalid KeySchema: The second KeySchemaElement is not a RANGE key type".to_string(),
932        );
933    }
934    Ok(())
935}
936
937fn validate_key_attrs_in_defs(
938    ks: &[KeySchemaElement],
939    defs: &[AttributeDefinition],
940) -> std::result::Result<(), String> {
941    // Collect missing key attribute names
942    let missing: Vec<&str> = ks
943        .iter()
944        .filter(|k| !defs.iter().any(|d| d.attribute_name == k.attribute_name))
945        .map(|k| k.attribute_name.as_str())
946        .collect();
947
948    if missing.is_empty() {
949        // Even if no keys are missing, check for structural issues (dup names/types)
950        // which DynamoDB reports as generic "no definition" when defs exist
951        let has_dup_names = ks.len() == 2 && ks[0].attribute_name == ks[1].attribute_name;
952        if has_dup_names {
953            return Err(
954                "Invalid KeySchema: Some index key attribute have no definition".to_string(),
955            );
956        }
957        return Ok(());
958    }
959
960    // Use generic message when:
961    // - defs is empty (fewer defs than unique key attrs)
962    // - key schema has 2 elements (regardless of structural validity)
963    // - key schema has structural issues (dup names, dup types)
964    // Use detailed message only when defs is non-empty AND key schema has 1 element
965    let has_dup_names = ks.len() == 2 && ks[0].attribute_name == ks[1].attribute_name;
966    let has_dup_types = ks.len() == 2 && ks[0].key_type == ks[1].key_type;
967    let use_generic = defs.is_empty() || ks.len() >= 2 || has_dup_names || has_dup_types;
968
969    if use_generic {
970        return Err("Invalid KeySchema: Some index key attribute have no definition".to_string());
971    }
972
973    // Detailed message for single-key schema with non-empty defs
974    let key_names: Vec<&str> = missing.to_vec();
975    let def_names: Vec<&str> = defs.iter().map(|d| d.attribute_name.as_str()).collect();
976    Err(format!(
977        "One or more parameter values were invalid: Some index key attributes are not defined in \
978         AttributeDefinitions. Keys: [{}], AttributeDefinitions: [{}]",
979        key_names.join(", "),
980        def_names.join(", ")
981    ))
982}
983
984fn validate_attr_def_count(
985    ks: &[KeySchemaElement],
986    defs: &[AttributeDefinition],
987    lsis: &Option<Vec<LocalSecondaryIndex>>,
988    gsis: &Option<Vec<GlobalSecondaryIndex>>,
989) -> std::result::Result<(), String> {
990    let mut all_key_attrs = std::collections::HashSet::new();
991    for k in ks {
992        all_key_attrs.insert(k.attribute_name.as_str());
993    }
994    if let Some(lsis) = lsis {
995        for lsi in lsis {
996            for k in &lsi.key_schema {
997                all_key_attrs.insert(k.attribute_name.as_str());
998            }
999        }
1000    }
1001    if let Some(gsis) = gsis {
1002        for gsi in gsis {
1003            for k in &gsi.key_schema {
1004                all_key_attrs.insert(k.attribute_name.as_str());
1005            }
1006        }
1007    }
1008    if defs.len() != all_key_attrs.len() {
1009        return Err("One or more parameter values were invalid: Number of attributes in KeySchema does not exactly match number of attributes defined in AttributeDefinitions".to_string());
1010    }
1011    Ok(())
1012}
1013
1014fn validate_lsi_list(
1015    lsis: &[LocalSecondaryIndex],
1016    ks: &[KeySchemaElement],
1017    defs: &[AttributeDefinition],
1018) -> std::result::Result<(), String> {
1019    // Empty check is done earlier in validate_typed_request
1020
1021    if !ks.iter().any(|k| k.key_type == KeyType::RANGE) {
1022        return Err("One or more parameter values were invalid: Table KeySchema does not have a range key, which is required when specifying a LocalSecondaryIndex".to_string());
1023    }
1024
1025    // Check missing attribute definitions across all LSI keys
1026    let def_names: Vec<&str> = defs.iter().map(|d| d.attribute_name.as_str()).collect();
1027    let mut missing_keys = Vec::new();
1028    for lsi in lsis {
1029        for k in &lsi.key_schema {
1030            if !def_names.contains(&k.attribute_name.as_str())
1031                && !missing_keys.contains(&k.attribute_name.as_str())
1032            {
1033                missing_keys.push(k.attribute_name.as_str());
1034            }
1035        }
1036    }
1037    if !missing_keys.is_empty() {
1038        let mut all_keys = Vec::new();
1039        for lsi in lsis {
1040            for k in &lsi.key_schema {
1041                if !all_keys.contains(&k.attribute_name.as_str()) {
1042                    all_keys.push(k.attribute_name.as_str());
1043                }
1044            }
1045        }
1046        return Err(format!(
1047            "One or more parameter values were invalid: Some index key attributes are not defined in AttributeDefinitions. Keys: [{}], AttributeDefinitions: [{}]",
1048            all_keys.join(", "),
1049            def_names.join(", ")
1050        ));
1051    }
1052
1053    // Structural validation for each LSI
1054    for lsi in lsis {
1055        validate_lsi_structure(lsi, ks)?;
1056    }
1057
1058    // Duplicate index names
1059    let mut seen = std::collections::HashSet::new();
1060    for lsi in lsis {
1061        if !seen.insert(&lsi.index_name) {
1062            return Err(format!(
1063                "One or more parameter values were invalid: Duplicate index name: {}",
1064                lsi.index_name
1065            ));
1066        }
1067    }
1068
1069    // Count limit
1070    if lsis.len() > 5 {
1071        return Err("One or more parameter values were invalid: Number of LocalSecondaryIndexes exceeds per-table limit of 5".to_string());
1072    }
1073
1074    Ok(())
1075}
1076
1077fn validate_gsi_list(
1078    gsis: &[GlobalSecondaryIndex],
1079    defs: &[AttributeDefinition],
1080    bm: &str,
1081) -> std::result::Result<(), String> {
1082    // Empty check is done earlier in validate_typed_request
1083
1084    // Check missing attribute definitions across all GSI keys
1085    let def_names: Vec<&str> = defs.iter().map(|d| d.attribute_name.as_str()).collect();
1086    let mut missing_keys = Vec::new();
1087    for gsi in gsis {
1088        for k in &gsi.key_schema {
1089            if !def_names.contains(&k.attribute_name.as_str())
1090                && !missing_keys.contains(&k.attribute_name.as_str())
1091            {
1092                missing_keys.push(k.attribute_name.as_str());
1093            }
1094        }
1095    }
1096    if !missing_keys.is_empty() {
1097        let mut all_keys = Vec::new();
1098        for gsi in gsis {
1099            for k in &gsi.key_schema {
1100                if !all_keys.contains(&k.attribute_name.as_str()) {
1101                    all_keys.push(k.attribute_name.as_str());
1102                }
1103            }
1104        }
1105        return Err(format!(
1106            "One or more parameter values were invalid: Some index key attributes are not defined in AttributeDefinitions. Keys: [{}], AttributeDefinitions: [{}]",
1107            all_keys.join(", "),
1108            def_names.join(", ")
1109        ));
1110    }
1111
1112    // Structural validation for each GSI
1113    for gsi in gsis {
1114        validate_gsi_structure(gsi)?;
1115    }
1116
1117    // Duplicate index names
1118    let mut seen = std::collections::HashSet::new();
1119    for gsi in gsis {
1120        if !seen.insert(&gsi.index_name) {
1121            return Err(format!(
1122                "One or more parameter values were invalid: Duplicate index name: {}",
1123                gsi.index_name
1124            ));
1125        }
1126    }
1127
1128    // Count limit
1129    if gsis.len() > 20 {
1130        return Err("One or more parameter values were invalid: GlobalSecondaryIndex count exceeds the per-table limit of 20".to_string());
1131    }
1132
1133    // PAY_PER_REQUEST billing mode check
1134    if bm == "PAY_PER_REQUEST" {
1135        for gsi in gsis {
1136            if gsi.provisioned_throughput.is_some() {
1137                return Err(format!(
1138                    "One or more parameter values were invalid: ProvisionedThroughput should not be specified for index: {} when BillingMode is PAY_PER_REQUEST",
1139                    gsi.index_name
1140                ));
1141            }
1142        }
1143    }
1144
1145    Ok(())
1146}
1147
1148fn validate_lsi_structure(
1149    lsi: &LocalSecondaryIndex,
1150    table_ks: &[KeySchemaElement],
1151) -> std::result::Result<(), String> {
1152    // Key schema structure first
1153    validate_key_schema_structure(&lsi.key_schema)?;
1154
1155    // Range key presence (before projection, per DynamoDB ordering)
1156    let lsi_sk = lsi.key_schema.iter().find(|k| k.key_type == KeyType::RANGE);
1157    if lsi_sk.is_none() {
1158        return Err(format!(
1159            "One or more parameter values were invalid: Index KeySchema does not have a range key for index: {}",
1160            lsi.index_name
1161        ));
1162    }
1163
1164    // Hash key must match table hash key (before projection)
1165    let table_pk = table_ks
1166        .iter()
1167        .find(|k| k.key_type == KeyType::HASH)
1168        .map(|k| k.attribute_name.as_str());
1169    let lsi_pk = lsi
1170        .key_schema
1171        .iter()
1172        .find(|k| k.key_type == KeyType::HASH)
1173        .map(|k| k.attribute_name.as_str());
1174    if lsi_pk != table_pk {
1175        return Err(format!(
1176            "One or more parameter values were invalid: \
1177             Index KeySchema does not have the same leading hash key as table KeySchema \
1178             for index: {}. index hash key: {}, table hash key: {}",
1179            lsi.index_name,
1180            lsi_pk.unwrap_or("null"),
1181            table_pk.unwrap_or("null")
1182        ));
1183    }
1184
1185    // Projection (after range key and hash key checks)
1186    validate_proj_structure(&lsi.projection)?;
1187
1188    Ok(())
1189}
1190
1191fn validate_gsi_structure(gsi: &GlobalSecondaryIndex) -> std::result::Result<(), String> {
1192    validate_key_schema_structure(&gsi.key_schema)?;
1193    validate_proj_structure(&gsi.projection)?;
1194    Ok(())
1195}
1196
1197fn validate_proj_structure(p: &Projection) -> std::result::Result<(), String> {
1198    // Matched on the projection type regardless of whether NonKeyAttributes is
1199    // present, so the INCLUDE-without-list case (DynamoDB rejects it) is
1200    // reachable. ALL and KEYS_ONLY must not carry a list; INCLUDE requires a
1201    // non-empty one. Reached by both GSI and LSI validation.
1202    match &p.projection_type {
1203        None => Err(
1204            "One or more parameter values were invalid: Unknown ProjectionType: null".to_string(),
1205        ),
1206        Some(ProjectionType::ALL) => match &p.non_key_attributes {
1207            Some(_) => Err("One or more parameter values were invalid: ProjectionType is ALL, but NonKeyAttributes is specified".to_string()),
1208            None => Ok(()),
1209        },
1210        Some(ProjectionType::KEYS_ONLY) => match &p.non_key_attributes {
1211            Some(_) => Err("One or more parameter values were invalid: ProjectionType is KEYS_ONLY, but NonKeyAttributes is specified".to_string()),
1212            None => Ok(()),
1213        },
1214        Some(ProjectionType::INCLUDE) => match &p.non_key_attributes {
1215            None => Err("One or more parameter values were invalid: ProjectionType is INCLUDE, but NonKeyAttributes is not specified".to_string()),
1216            Some(nka) if nka.is_empty() => Err("One or more parameter values were invalid: NonKeyAttributes must not be empty".to_string()),
1217            Some(_) => Ok(()),
1218        },
1219    }
1220}