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::{BackendError, StorageBackend};
4use crate::streams;
5use crate::types::{
6    AttributeDefinition, GlobalSecondaryIndex, KeySchemaElement, KeyType, LocalSecondaryIndex,
7    Projection, ProjectionType, ProvisionedThroughput, VectorIndex,
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 = "VectorIndexes", default)]
27    vector_indexes: Option<serde_json::Value>,
28    #[serde(rename = "BillingMode", default)]
29    billing_mode: Option<String>,
30    #[serde(rename = "ProvisionedThroughput", default)]
31    provisioned_throughput: Option<serde_json::Value>,
32    #[serde(rename = "StreamSpecification", default)]
33    stream_specification: Option<StreamSpecification>,
34    #[serde(rename = "SSESpecification", default)]
35    sse_specification: Option<crate::types::SseSpecification>,
36    #[serde(rename = "TableClass", default)]
37    table_class: Option<String>,
38    #[serde(rename = "Tags", default)]
39    tags: Option<Vec<crate::types::Tag>>,
40    #[serde(rename = "DeletionProtectionEnabled", default)]
41    deletion_protection_enabled: Option<bool>,
42    #[serde(rename = "OnDemandThroughput", default)]
43    on_demand_throughput: Option<crate::types::OnDemandThroughput>,
44}
45
46/// Public request type: fully validated, typed fields.
47/// Can be constructed directly (programmatic use) or deserialized from JSON.
48#[derive(Debug, Default)]
49pub struct CreateTableRequest {
50    pub table_name: String,
51    pub key_schema: Vec<KeySchemaElement>,
52    pub attribute_definitions: Vec<AttributeDefinition>,
53    pub global_secondary_indexes: Option<Vec<GlobalSecondaryIndex>>,
54    pub local_secondary_indexes: Option<Vec<LocalSecondaryIndex>>,
55    pub vector_indexes: Option<Vec<VectorIndex>>,
56    pub billing_mode: Option<String>,
57    pub provisioned_throughput: Option<ProvisionedThroughput>,
58    pub stream_specification: Option<StreamSpecification>,
59    pub sse_specification: Option<crate::types::SseSpecification>,
60    pub table_class: Option<String>,
61    pub tags: Option<Vec<crate::types::Tag>>,
62    pub deletion_protection_enabled: Option<bool>,
63    pub on_demand_throughput: Option<crate::types::OnDemandThroughput>,
64}
65
66/// Custom Deserialize that does loose JSON parsing first, validates, then builds typed fields.
67/// Validation errors use "VALIDATION:" prefix so server.rs converts them to ValidationException.
68impl<'de> serde::Deserialize<'de> for CreateTableRequest {
69    fn deserialize<D: serde::Deserializer<'de>>(
70        deserializer: D,
71    ) -> std::result::Result<Self, D::Error> {
72        let raw = RawRequest::deserialize(deserializer)?;
73        match validate_raw_and_build(raw) {
74            Ok(req) => Ok(req),
75            Err(msg) => Err(serde::de::Error::custom(format!("VALIDATION:{}", msg))),
76        }
77    }
78}
79
80#[derive(Debug, Default, Deserialize)]
81pub struct StreamSpecification {
82    #[serde(rename = "StreamEnabled", alias = "stream_enabled")]
83    pub stream_enabled: bool,
84    #[serde(rename = "StreamViewType", alias = "stream_view_type", default)]
85    pub stream_view_type: Option<String>,
86}
87
88#[derive(Debug, Default, Serialize)]
89pub struct CreateTableResponse {
90    #[serde(rename = "TableDescription")]
91    pub table_description: TableDescription,
92}
93
94pub async fn execute<S: StorageBackend>(
95    storage: &S,
96    mut request: CreateTableRequest,
97) -> Result<CreateTableResponse> {
98    // An OnDemandThroughput object with no members is equivalent to omitting
99    // it: real DynamoDB accepts it at creation and stores nothing (eu-west-2
100    // capture, 2026-07-24), so normalise before any gate looks at it.
101    if request.on_demand_throughput.as_ref().is_some_and(|odt| {
102        odt.max_read_request_units.is_none() && odt.max_write_request_units.is_none()
103    }) {
104        request.on_demand_throughput = None;
105    }
106
107    // Structural validation (runs for both programmatic and JSON paths)
108    validate_typed_request(&request)?;
109
110    if let Some(ref bm) = request.billing_mode
111        && bm != "PROVISIONED"
112        && bm != "PAY_PER_REQUEST"
113    {
114        return Err(DynoxideError::ValidationException(format!(
115            "1 validation error detected: Value '{bm}' at 'billingMode' failed to satisfy \
116                 constraint: Member must satisfy enum value set: \
117                 [PROVISIONED, PAY_PER_REQUEST]"
118        )));
119    }
120
121    if let Some(ref tc) = request.table_class
122        && tc != "STANDARD"
123        && tc != "STANDARD_INFREQUENT_ACCESS"
124    {
125        return Err(DynoxideError::ValidationException(format!(
126            "1 validation error detected: Value '{tc}' at 'tableClass' failed to satisfy \
127                 constraint: Member must satisfy enum value set: \
128                 [STANDARD, STANDARD_INFREQUENT_ACCESS]"
129        )));
130    }
131
132    // OnDemandThroughput is only valid alongside PAY_PER_REQUEST billing, and
133    // its members must be at least 1 at creation (-1 is an UpdateTable-only
134    // removal marker). Both messages captured from real DynamoDB (eu-west-2,
135    // 2026-07-24); the gate names the first present member, read checked
136    // first, and the create wording drops "the" and carries a full stop.
137    if let Some(ref odt) = request.on_demand_throughput {
138        let members = [
139            ("MaxReadRequestUnits", odt.max_read_request_units),
140            ("MaxWriteRequestUnits", odt.max_write_request_units),
141        ];
142        if let Some((member, _)) = members.iter().find(|(_, v)| v.is_some()) {
143            let billing_mode_str = request.billing_mode.as_deref().unwrap_or("PROVISIONED");
144            if billing_mode_str == "PROVISIONED" {
145                return Err(DynoxideError::ValidationException(format!(
146                    "One or more parameter values were invalid: {member} for \
147                     OnDemandThroughput cannot be specified when table BillingMode \
148                     is PROVISIONED."
149                )));
150            }
151        }
152        for (member, value) in members {
153            if value.is_some_and(|v| v < 1) {
154                return Err(DynoxideError::ValidationException(format!(
155                    "One or more parameter values were invalid: Requested {member} for \
156                     OnDemandThroughput for table is outside of valid range"
157                )));
158            }
159        }
160    }
161
162    if storage.table_exists(&request.table_name).await? {
163        return Err(DynoxideError::ResourceInUseException(format!(
164            "Table already exists: {}",
165            request.table_name
166        )));
167    }
168
169    let now = SystemTime::now()
170        .duration_since(UNIX_EPOCH)
171        .unwrap_or_default()
172        .as_secs() as i64;
173
174    let key_schema_json = serde_json::to_string(&request.key_schema)
175        .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
176    let attr_defs_json = serde_json::to_string(&request.attribute_definitions)
177        .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
178    let gsi_json = request
179        .global_secondary_indexes
180        .as_ref()
181        .map(serde_json::to_string)
182        .transpose()
183        .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
184    let lsi_json = request
185        .local_secondary_indexes
186        .as_ref()
187        .map(serde_json::to_string)
188        .transpose()
189        .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
190    let vector_json = request
191        .vector_indexes
192        .as_ref()
193        .map(serde_json::to_string)
194        .transpose()
195        .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
196    let pt_json = request
197        .provisioned_throughput
198        .as_ref()
199        .map(serde_json::to_string)
200        .transpose()
201        .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
202    // Normalise the SSE spec so a DescribeTable round-trip matches AWS: when
203    // encryption is enabled without an explicit type/key, AWS reports SSEType=KMS
204    // and a KMS key ARN (the AWS-managed `aws/dynamodb` key). Persisting the
205    // synthesised key id keeps the reported ARN stable across DescribeTable calls.
206    let normalized_sse = request.sse_specification.as_ref().map(|spec| {
207        if spec.enabled == Some(true) {
208            crate::types::SseSpecification {
209                enabled: Some(true),
210                sse_type: spec.sse_type.clone().or_else(|| Some("KMS".to_string())),
211                kms_master_key_id: spec.kms_master_key_id.clone().or_else(|| {
212                    Some(crate::streams::kms_key_arn(
213                        &uuid::Uuid::new_v4().to_string(),
214                    ))
215                }),
216            }
217        } else {
218            spec.clone()
219        }
220    });
221    let sse_json = normalized_sse
222        .as_ref()
223        .map(serde_json::to_string)
224        .transpose()
225        .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
226    let on_demand_json = request
227        .on_demand_throughput
228        .as_ref()
229        .map(serde_json::to_string)
230        .transpose()
231        .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
232    let deletion_protection = request.deletion_protection_enabled.unwrap_or(false);
233
234    // Refuse capability gaps before creating anything. A refusal after
235    // `insert_table_metadata` would leave the table half-created: the client
236    // sees an error, retries, and finds ResourceInUseException where the real
237    // answer is that the backend cannot honour the request at all.
238    if let Some(ref spec) = request.stream_specification
239        && spec.stream_enabled
240        && !storage.supports_streams()
241    {
242        return Err(BackendError::Unsupported {
243            capability: "streams",
244        }
245        .into());
246    }
247    if request.tags.as_ref().is_some_and(|tags| !tags.is_empty()) && !storage.supports_tags() {
248        return Err(BackendError::Unsupported { capability: "tags" }.into());
249    }
250
251    let billing_mode_str = request.billing_mode.as_deref().unwrap_or("PROVISIONED");
252    storage
253        .insert_table_metadata(&crate::storage::CreateTableMetadata {
254            table_name: &request.table_name,
255            key_schema: &key_schema_json,
256            attribute_definitions: &attr_defs_json,
257            gsi_definitions: gsi_json.as_deref(),
258            lsi_definitions: lsi_json.as_deref(),
259            provisioned_throughput: pt_json.as_deref(),
260            created_at: now,
261            sse_specification: sse_json.as_deref(),
262            table_class: request.table_class.as_deref(),
263            deletion_protection_enabled: deletion_protection,
264            billing_mode: Some(billing_mode_str),
265            on_demand_throughput: on_demand_json.as_deref(),
266            vector_index_definitions: vector_json.as_deref(),
267        })
268        .await?;
269
270    storage.create_data_table(&request.table_name).await?;
271
272    if let Some(ref gsis) = request.global_secondary_indexes {
273        for gsi in gsis {
274            storage
275                .create_gsi_table(&request.table_name, &gsi.index_name)
276                .await?;
277        }
278    }
279
280    if let Some(ref lsis) = request.local_secondary_indexes {
281        for lsi in lsis {
282            storage
283                .create_lsi_table(&request.table_name, &lsi.index_name)
284                .await?;
285        }
286    }
287
288    if let Some(ref vixs) = request.vector_indexes {
289        for vix in vixs {
290            storage
291                .create_vector_table(&request.table_name, &vix.index_name)
292                .await?;
293        }
294    }
295
296    if let Some(ref spec) = request.stream_specification
297        && spec.stream_enabled
298    {
299        let view_type = spec
300            .stream_view_type
301            .as_deref()
302            .unwrap_or("NEW_AND_OLD_IMAGES");
303        let label = streams::generate_stream_label(storage.clock());
304        storage
305            .enable_stream(&request.table_name, view_type, &label)
306            .await?;
307    }
308
309    if let Some(ref tags) = request.tags
310        && !tags.is_empty()
311    {
312        storage.set_tags(&request.table_name, tags).await?;
313    }
314
315    let meta = storage
316        .get_table_metadata(&request.table_name)
317        .await?
318        .ok_or_else(|| {
319            DynoxideError::InternalServerError("Table metadata not found after creation".into())
320        })?;
321
322    // An index created with its table is never armed: measured in eu-west-2 on
323    // 2026-08-21 it reaches ACTIVE in the same DescribeTable poll as the table,
324    // so there is no window to report and an empty phase set says exactly that.
325    let mut desc = build_table_description(
326        &meta,
327        Some(0),
328        Some(0),
329        &crate::actions::vector_lifecycle::VectorIndexPhases::default(),
330    );
331    // CreateTable response shows CREATING status (table is usable immediately
332    // but DynamoDB API contract says newly-created tables start as CREATING)
333    desc.table_status = "CREATING".to_string();
334
335    // Override billing mode fields based on the actual request
336    let billing_mode_str = request.billing_mode.as_deref().unwrap_or("PROVISIONED");
337    if billing_mode_str == "PROVISIONED" {
338        desc.billing_mode_summary = None;
339        desc.table_throughput_mode_summary = None;
340    } else if billing_mode_str == "PAY_PER_REQUEST" {
341        desc.billing_mode_summary = Some(crate::actions::BillingModeSummary {
342            billing_mode: "PAY_PER_REQUEST".to_string(),
343            last_update_to_pay_per_request_date_time: None,
344        });
345        desc.table_throughput_mode_summary = Some(crate::actions::TableThroughputModeSummary {
346            table_throughput_mode: "PAY_PER_REQUEST".to_string(),
347            last_update_to_pay_per_request_date_time: None,
348        });
349        // Ensure provisioned throughput shows zeros for PAY_PER_REQUEST
350        desc.provisioned_throughput = Some(crate::actions::TableProvisionedThroughputDescription {
351            read_capacity_units: 0,
352            write_capacity_units: 0,
353            number_of_decreases_today: 0,
354            last_increase_date_time: None,
355            last_decrease_date_time: None,
356        });
357    }
358
359    // Set all GSI statuses to CREATING for newly created tables
360    if let Some(ref mut gsis) = desc.global_secondary_indexes {
361        for gsi in gsis {
362            gsi.index_status = "CREATING".to_string();
363        }
364    }
365
366    // Vector indexes likewise report CREATING in the CreateTable response;
367    // DescribeTable then reports ACTIVE with Backfilling never present on
368    // this path (captured from real DynamoDB, eu-west-2, 2026-08-11).
369    if let Some(ref mut vixs) = desc.vector_indexes {
370        for vix in vixs {
371            vix.index_status = "CREATING".to_string();
372        }
373    }
374
375    // Remove DeletionProtectionEnabled from response if not explicitly set
376    // (DynamoDB doesn't include it in basic CreateTable response)
377    if request.deletion_protection_enabled.is_none() {
378        desc.deletion_protection_enabled = None;
379    }
380
381    Ok(CreateTableResponse {
382        table_description: desc,
383    })
384}
385
386/// Convert a String error to DynoxideError::ValidationException.
387fn ve(msg: String) -> DynoxideError {
388    DynoxideError::ValidationException(msg)
389}
390
391/// Validate a programmatically-constructed request (used when not deserialised from JSON).
392///
393/// The validation order matches DynamoDB's actual behaviour (as verified by the Dynalite
394/// conformance suite):
395///
396/// 1. Table name (missing, length, pattern)
397/// 2. BillingMode + ProvisionedThroughput consistency
398/// 3. ProvisionedThroughput out-of-bounds
399/// 4. Missing ProvisionedThroughput (default PROVISIONED billing)
400/// 5. Key attribute definition checks ("Invalid KeySchema" / detailed missing-attr message)
401/// 6. Key schema structure (duplicate names, wrong types)
402/// 7. Empty LSI/GSI/VectorIndexes lists
403/// 8. LSI/GSI structural validation (key schema, projections, duplicates, limits)
404/// 9. Vector index validation (billing-mode gate, per-index structure,
405///    duplicates, count limit, conflicting dimensions)
406/// 10. Cross-index duplicate names (LSI/GSI/vector share one namespace)
407/// 11. Attribute definition count mismatch (SearchSchema attributes join the
408///     used set)
409/// 12. StreamSpecification consistency (a disabled stream must not set a view type)
410fn validate_typed_request(request: &CreateTableRequest) -> Result<()> {
411    if request.table_name.is_empty() {
412        return Err(DynoxideError::ValidationException(
413            "The parameter 'TableName' is required but was not present in the request".to_string(),
414        ));
415    }
416    if request.table_name.len() < 3 || request.table_name.len() > 255 {
417        return Err(DynoxideError::ValidationException(
418            "TableName must be at least 3 characters long and at most 255 characters long"
419                .to_string(),
420        ));
421    }
422
423    // Table name pattern
424    if !request
425        .table_name
426        .chars()
427        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
428    {
429        return Err(DynoxideError::ValidationException(format!(
430            "1 validation error detected: Value '{}' at 'tableName' failed to satisfy constraint: \
431             Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+",
432            request.table_name
433        )));
434    }
435
436    // BillingMode + ProvisionedThroughput consistency
437    let billing_mode_str = request.billing_mode.as_deref().unwrap_or("PROVISIONED");
438    if billing_mode_str == "PAY_PER_REQUEST" && request.provisioned_throughput.is_some() {
439        return Err(DynoxideError::ValidationException(
440            "One or more parameter values were invalid: Neither ReadCapacityUnits nor \
441             WriteCapacityUnits can be specified when BillingMode is PAY_PER_REQUEST"
442                .to_string(),
443        ));
444    }
445
446    // ProvisionedThroughput out-of-bounds
447    if let Some(ref pt) = request.provisioned_throughput {
448        const MAX_THROUGHPUT: i64 = 1_000_000_000_000;
449        let rcu = pt.read_capacity_units.unwrap_or(0);
450        let wcu = pt.write_capacity_units.unwrap_or(0);
451        if rcu > MAX_THROUGHPUT {
452            return Err(DynoxideError::ValidationException(format!(
453                "Given value {} for ReadCapacityUnits is out of bounds",
454                rcu
455            )));
456        }
457        if wcu > MAX_THROUGHPUT {
458            return Err(DynoxideError::ValidationException(format!(
459                "Given value {} for WriteCapacityUnits is out of bounds",
460                wcu
461            )));
462        }
463    }
464
465    // Missing ProvisionedThroughput when billing mode is explicitly PROVISIONED.
466    // For the programmatic API, when BillingMode is not specified we default to
467    // PAY_PER_REQUEST for convenience. The HTTP/JSON path (validate_raw_and_build)
468    // applies the stricter DynamoDB default of PROVISIONED.
469    if request.billing_mode.is_some()
470        && billing_mode_str == "PROVISIONED"
471        && request.provisioned_throughput.is_none()
472    {
473        return Err(DynoxideError::ValidationException(
474            "One or more parameter values were invalid: ReadCapacityUnits and \
475             WriteCapacityUnits must both be specified when BillingMode is PROVISIONED"
476                .to_string(),
477        ));
478    }
479
480    // Key attribute definition checks (before key schema structure)
481    validate_key_attrs_in_defs(&request.key_schema, &request.attribute_definitions).map_err(ve)?;
482
483    // Key schema structure
484    validate_key_schema_structure(&request.key_schema).map_err(ve)?;
485
486    // Empty LSI/GSI lists (before structural validation and attr count)
487    if let Some(ref lsis) = request.local_secondary_indexes
488        && lsis.is_empty()
489    {
490        return Err(ve(
491            "One or more parameter values were invalid: List of LocalSecondaryIndexes is empty"
492                .to_string(),
493        ));
494    }
495    if let Some(ref gsis) = request.global_secondary_indexes
496        && gsis.is_empty()
497    {
498        return Err(ve(
499            "One or more parameter values were invalid: List of GlobalSecondaryIndexes is empty"
500                .to_string(),
501        ));
502    }
503    // An empty VectorIndexes list is rejected, not normalised to absent.
504    // Captured from real DynamoDB (eu-west-2 and us-east-1, 2026-08-12).
505    if let Some(ref vixs) = request.vector_indexes
506        && vixs.is_empty()
507    {
508        return Err(ve(
509            "One or more parameter values were invalid: List of VectorIndexes is empty".to_string(),
510        ));
511    }
512
513    // LSI structural validation
514    if let Some(ref lsis) = request.local_secondary_indexes {
515        validate_lsi_list(lsis, &request.key_schema, &request.attribute_definitions).map_err(ve)?;
516    }
517
518    // GSI structural validation
519    if let Some(ref gsis) = request.global_secondary_indexes {
520        let bm = request.billing_mode.as_deref().unwrap_or("PROVISIONED");
521        validate_gsi_list(gsis, &request.attribute_definitions, bm).map_err(ve)?;
522    }
523
524    // Vector index validation (the empty list was rejected above)
525    if let Some(ref vixs) = request.vector_indexes {
526        let bm = request.billing_mode.as_deref().unwrap_or("PROVISIONED");
527        validate_vector_index_list(vixs, &request.attribute_definitions, bm)?;
528    }
529
530    // Cross-index duplicate names (checked before attr def count)
531    check_cross_index_duplicates(
532        &request.local_secondary_indexes,
533        &request.global_secondary_indexes,
534        &request.vector_indexes,
535    )
536    .map_err(ve)?;
537
538    // Attribute definition count (last of the key/index checks). SearchSchema
539    // attributes count towards the used set, so declaring them does not trip
540    // the exact-match rule.
541    validate_attr_def_count(
542        &request.key_schema,
543        &request.attribute_definitions,
544        &request.local_secondary_indexes,
545        &request.global_secondary_indexes,
546        &request.vector_indexes,
547    )
548    .map_err(ve)?;
549
550    // StreamSpecification consistency: a disabled stream must not carry a view
551    // type. Real DynamoDB rejects the combination, because a view type only has
552    // meaning when the stream is enabled.
553    if let Some(ref spec) = request.stream_specification
554        && !spec.stream_enabled
555        && spec.stream_view_type.is_some()
556    {
557        return Err(DynoxideError::ValidationException(
558            "One or more parameter values were invalid: Table is being created with a stream \
559                 disabled, UpdateViewType should not be specified"
560                .to_string(),
561        ));
562    }
563
564    Ok(())
565}
566
567/// List-level vector index validation, shared by the JSON and programmatic
568/// paths via `validate_typed_request`. All operation-layer messages here are
569/// captured from real DynamoDB (eu-west-2, 2026-08-11); the duplicate-name
570/// form is the classic secondary-index message, captured for vector indexes
571/// too, both same-family and cross-family (eu-west-2 and us-east-1,
572/// 2026-08-12).
573///
574/// Returns the crate `Result` rather than the `Result<(), String>` of the
575/// `validate_lsi_list`/`validate_gsi_list` siblings: `validate_vector_index`
576/// raises typed `DynoxideError`s at source, so there is no string layer to
577/// map through here.
578fn validate_vector_index_list(
579    vixs: &[VectorIndex],
580    defs: &[AttributeDefinition],
581    bm: &str,
582) -> Result<()> {
583    if bm != "PAY_PER_REQUEST" {
584        return Err(ve(
585            "One or more parameter values were invalid: Vector indexes are only supported \
586             for PAY_PER_REQUEST tables"
587                .to_string(),
588        ));
589    }
590
591    for (i, vix) in vixs.iter().enumerate() {
592        crate::validation::validate_vector_index(
593            vix,
594            defs,
595            &format!("vectorIndexes.{}.member", i + 1),
596        )?;
597    }
598
599    // Duplicate index names within the vector list.
600    let mut seen = std::collections::HashSet::new();
601    for vix in vixs {
602        if !seen.insert(&vix.index_name) {
603            return Err(ve(format!(
604                "One or more parameter values were invalid: Duplicate index name: {}",
605                vix.index_name
606            )));
607        }
608    }
609
610    // Count limit (five per table).
611    if vixs.len() > 5 {
612        return Err(ve(
613            "One or more parameter values were invalid: VectorIndex count exceeds the \
614             per-table limit of 5"
615                .to_string(),
616        ));
617    }
618
619    // Two indexes on one vector attribute must agree on dimensions.
620    let mut dims_by_attr: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();
621    for vix in vixs {
622        let attr = vix.vector_attribute.attribute_name.as_str();
623        match dims_by_attr.get(attr) {
624            Some(&prev) if prev != vix.dimensions => {
625                return Err(ve(format!(
626                    "One or more parameter values were invalid: Conflicting attribute \
627                     definition for '{attr}'. All VectorIndexes on the same vector attribute \
628                     must use the same dimensions."
629                )));
630            }
631            Some(_) => {}
632            None => {
633                dims_by_attr.insert(attr, vix.dimensions);
634            }
635        }
636    }
637
638    // The vector attribute must not itself be declared in AttributeDefinitions:
639    // it is not a key attribute, and declaring it is rejected outright.
640    // Captured from real DynamoDB (eu-west-2 and us-east-1, 2026-08-12).
641    for vix in vixs {
642        let attr = vix.vector_attribute.attribute_name.as_str();
643        if defs.iter().any(|d| d.attribute_name == attr) {
644            return Err(ve(format!(
645                "One or more parameter values were invalid: Conflicting attribute definition \
646                 for '{attr}'. An attribute cannot be defined in AttributeDefinitions when \
647                 used as a VectorAttribute."
648            )));
649        }
650    }
651
652    Ok(())
653}
654
655/// Duplicate index names across the LSI, GSI, and vector index families.
656/// LSIs, GSIs, and vector indexes share one name namespace; the classic
657/// duplicate message covers a vector index colliding with either secondary
658/// index family too (captured from real DynamoDB, eu-west-2 and us-east-1,
659/// 2026-08-12). Duplicates within a single family are reported by that
660/// family's own list validation, which runs first.
661fn check_cross_index_duplicates(
662    lsis: &Option<Vec<LocalSecondaryIndex>>,
663    gsis: &Option<Vec<GlobalSecondaryIndex>>,
664    vixs: &Option<Vec<VectorIndex>>,
665) -> std::result::Result<(), String> {
666    let mut all_names = std::collections::HashSet::new();
667    if let Some(lsis) = lsis {
668        for lsi in lsis {
669            all_names.insert(&lsi.index_name);
670        }
671    }
672    if let Some(gsis) = gsis {
673        for gsi in gsis {
674            if !all_names.insert(&gsi.index_name) {
675                return Err(format!(
676                    "One or more parameter values were invalid: Duplicate index name: {}",
677                    gsi.index_name
678                ));
679            }
680        }
681    }
682    if let Some(vixs) = vixs {
683        for vix in vixs {
684            if !all_names.insert(&vix.index_name) {
685                return Err(format!(
686                    "One or more parameter values were invalid: Duplicate index name: {}",
687                    vix.index_name
688                ));
689            }
690        }
691    }
692    Ok(())
693}
694
695// ---- Raw JSON validation (for deserialization path) ----
696
697fn validate_raw_and_build(raw: RawRequest) -> std::result::Result<CreateTableRequest, String> {
698    // Missing TableName is a different error format from invalid TableName
699    if raw.table_name.is_none() {
700        return Err(
701            "The parameter 'TableName' is required but was not present in the request".to_string(),
702        );
703    }
704
705    // Use the shared constraint error collector for table name validation.
706    // This produces the correct multi-field constraint format for empty,
707    // too-short, too-long, or invalid-pattern table names.
708    let name_errors = crate::validation::table_name_constraint_errors(
709        raw.table_name.as_deref(),
710        crate::validation::TableNameContext::CreateTable,
711    );
712    if !name_errors.is_empty() {
713        let msg = format!(
714            "{} validation error{} detected: {}",
715            name_errors.len(),
716            if name_errors.len() > 1 { "s" } else { "" },
717            name_errors.join("; ")
718        );
719        return Err(msg);
720    }
721    let table_name = raw.table_name.unwrap();
722
723    let mut errors = Vec::new();
724
725    if let Some(ref bm) = raw.billing_mode
726        && bm != "PROVISIONED"
727        && bm != "PAY_PER_REQUEST"
728    {
729        errors.push(format!(
730            "Value '{}' at 'billingMode' failed to satisfy constraint: \
731                 Member must satisfy enum value set: [PROVISIONED, PAY_PER_REQUEST]",
732            bm
733        ));
734    }
735
736    collect_pt_errors(&raw.provisioned_throughput, &mut errors);
737    collect_ks_errors(&raw.key_schema, &mut errors);
738    collect_ad_errors(&raw.attribute_definitions, &mut errors);
739    // The GSIs' member errors come before the LSIs': with a too-short name on
740    // both, DynamoDB lists the GSI's first, whichever order the request gives
741    // them in (captured eu-west-2 and us-east-1, 2026-10-09).
742    collect_gsi_errors(&raw.global_secondary_indexes, &mut errors);
743    collect_lsi_errors(&raw.local_secondary_indexes, &mut errors);
744    collect_vix_errors(&raw.vector_indexes, &mut errors);
745
746    // DynamoDB caps multi-field constraint errors at 10
747    errors.truncate(10);
748
749    if !errors.is_empty() {
750        let prefix = format!(
751            "{} validation error{} detected: ",
752            errors.len(),
753            if errors.len() == 1 { "" } else { "s" }
754        );
755        return Err(format!("{}{}", prefix, errors.join("; ")));
756    }
757
758    // BillingMode + ProvisionedThroughput consistency (HTTP path only)
759    let billing_mode_str = raw.billing_mode.as_deref().unwrap_or("PROVISIONED");
760    if billing_mode_str == "PAY_PER_REQUEST" && raw.provisioned_throughput.is_some() {
761        return Err(
762            "One or more parameter values were invalid: Neither ReadCapacityUnits nor \
763             WriteCapacityUnits can be specified when BillingMode is PAY_PER_REQUEST"
764                .to_string(),
765        );
766    }
767
768    // ProvisionedThroughput out-of-bounds (after multi-field but before struct checks)
769    if let Some(ref pt) = raw.provisioned_throughput
770        && let Some(obj) = pt.as_object()
771    {
772        let rcu = obj
773            .get("ReadCapacityUnits")
774            .and_then(|v| v.as_i64())
775            .unwrap_or(0);
776        let wcu = obj
777            .get("WriteCapacityUnits")
778            .and_then(|v| v.as_i64())
779            .unwrap_or(0);
780        const MAX_THROUGHPUT: i64 = 1_000_000_000_000;
781        if rcu > MAX_THROUGHPUT {
782            return Err(format!(
783                "Given value {} for ReadCapacityUnits is out of bounds",
784                rcu
785            ));
786        }
787        if wcu > MAX_THROUGHPUT {
788            return Err(format!(
789                "Given value {} for WriteCapacityUnits is out of bounds",
790                wcu
791            ));
792        }
793    }
794
795    // Missing ProvisionedThroughput when BillingMode is explicitly PROVISIONED.
796    if raw.billing_mode.as_deref() == Some("PROVISIONED") && raw.provisioned_throughput.is_none() {
797        return Err(
798            "One or more parameter values were invalid: ReadCapacityUnits and \
799             WriteCapacityUnits must both be specified when BillingMode is PROVISIONED"
800                .to_string(),
801        );
802    }
803
804    // Parse typed structures
805    let key_schema: Vec<KeySchemaElement> = raw
806        .key_schema
807        .as_ref()
808        .map(|v| serde_json::from_value(v.clone()))
809        .transpose()
810        .map_err(|e| e.to_string())?
811        .unwrap_or_default();
812    let attribute_definitions: Vec<AttributeDefinition> = raw
813        .attribute_definitions
814        .as_ref()
815        .map(|v| serde_json::from_value(v.clone()))
816        .transpose()
817        .map_err(|e| e.to_string())?
818        .unwrap_or_default();
819    let provisioned_throughput: Option<ProvisionedThroughput> = raw
820        .provisioned_throughput
821        .as_ref()
822        .map(|v| serde_json::from_value(v.clone()))
823        .transpose()
824        .map_err(|e| e.to_string())?;
825    let global_secondary_indexes: Option<Vec<GlobalSecondaryIndex>> = raw
826        .global_secondary_indexes
827        .as_ref()
828        .map(|v| serde_json::from_value(v.clone()))
829        .transpose()
830        .map_err(|e| e.to_string())?;
831    let local_secondary_indexes: Option<Vec<LocalSecondaryIndex>> = raw
832        .local_secondary_indexes
833        .as_ref()
834        .map(|v| serde_json::from_value(v.clone()))
835        .transpose()
836        .map_err(|e| e.to_string())?;
837    let vector_indexes: Option<Vec<VectorIndex>> = raw
838        .vector_indexes
839        .as_ref()
840        .map(|v| serde_json::from_value(normalise_vix_dimensions(v)))
841        .transpose()
842        .map_err(|e| e.to_string())?;
843
844    Ok(CreateTableRequest {
845        table_name,
846        key_schema,
847        attribute_definitions,
848        global_secondary_indexes,
849        local_secondary_indexes,
850        vector_indexes,
851        billing_mode: raw.billing_mode,
852        provisioned_throughput,
853        stream_specification: raw.stream_specification,
854        sse_specification: raw.sse_specification,
855        table_class: raw.table_class,
856        tags: raw.tags,
857        deletion_protection_enabled: raw.deletion_protection_enabled,
858        on_demand_throughput: raw.on_demand_throughput,
859    })
860}
861
862// ---- Multi-field constraint error collectors ----
863
864fn collect_pt_errors(pt_val: &Option<serde_json::Value>, errors: &mut Vec<String>) {
865    if let Some(v) = pt_val
866        && let Some(obj) = v.as_object()
867    {
868        let wcu = obj.get("WriteCapacityUnits");
869        let rcu = obj.get("ReadCapacityUnits");
870        if wcu.is_none() || wcu == Some(&serde_json::Value::Null) {
871            errors.push("Value null at 'provisionedThroughput.writeCapacityUnits' failed to satisfy constraint: Member must not be null".to_string());
872        } else if let Some(w) = wcu.and_then(|v| v.as_i64())
873            && w < 1
874        {
875            errors.push(format!("Value '{}' at 'provisionedThroughput.writeCapacityUnits' failed to satisfy constraint: Member must have value greater than or equal to 1", w));
876        }
877        if rcu.is_none() || rcu == Some(&serde_json::Value::Null) {
878            errors.push("Value null at 'provisionedThroughput.readCapacityUnits' failed to satisfy constraint: Member must not be null".to_string());
879        } else if let Some(r) = rcu.and_then(|v| v.as_i64())
880            && r < 1
881        {
882            errors.push(format!("Value '{}' at 'provisionedThroughput.readCapacityUnits' failed to satisfy constraint: Member must have value greater than or equal to 1", r));
883        }
884    }
885}
886
887fn collect_ks_errors(ks_val: &Option<serde_json::Value>, errors: &mut Vec<String>) {
888    match ks_val {
889        None => {
890            errors.push(
891                "Value null at 'keySchema' failed to satisfy constraint: Member must not be null"
892                    .to_string(),
893            );
894        }
895        Some(v) => {
896            if let Some(arr) = v.as_array() {
897                if arr.is_empty() {
898                    errors.push("Value '[]' at 'keySchema' failed to satisfy constraint: Member must have length greater than or equal to 1".to_string());
899                } else if arr.len() > 2 {
900                    let dump = render_key_schema_java_toString(arr);
901                    errors.push(format!("Value '{}' at 'keySchema' failed to satisfy constraint: Member must have length less than or equal to 2", dump));
902                }
903                for (i, elem) in arr.iter().enumerate().take(10) {
904                    collect_ks_elem_errors(elem, i + 1, errors);
905                }
906            }
907        }
908    }
909}
910
911fn collect_ks_elem_errors(elem: &serde_json::Value, idx: usize, errors: &mut Vec<String>) {
912    if let Some(obj) = elem.as_object() {
913        if !obj.contains_key("AttributeName")
914            || obj.get("AttributeName") == Some(&serde_json::Value::Null)
915        {
916            errors.push(format!("Value null at 'keySchema.{}.member.attributeName' failed to satisfy constraint: Member must not be null", idx));
917        }
918        let kt = obj.get("KeyType");
919        if kt.is_none() || kt == Some(&serde_json::Value::Null) {
920            errors.push(format!("Value null at 'keySchema.{}.member.keyType' failed to satisfy constraint: Member must not be null", idx));
921        } else if let Some(s) = kt.and_then(|v| v.as_str())
922            && s != "HASH"
923            && s != "RANGE"
924        {
925            errors.push(format!("Value '{}' at 'keySchema.{}.member.keyType' failed to satisfy constraint: Member must satisfy enum value set: [HASH, RANGE]", s, idx));
926        }
927    }
928}
929
930/// Render a KeySchema array the way AWS does in validation messages: Java's
931/// default toString() shape over the SDK's KeySchemaElement model, e.g.
932/// `[KeySchemaElement(attributeName=pk, keyType=HASH), KeySchemaElement(attributeName=sk, keyType=RANGE)]`.
933/// Missing attribute fields render as the empty string, matching what AWS
934/// emits when the upstream object hasn't been populated.
935#[allow(non_snake_case)]
936fn render_key_schema_java_toString(arr: &[serde_json::Value]) -> String {
937    let parts: Vec<String> = arr
938        .iter()
939        .map(|elem| {
940            let an = elem
941                .get("AttributeName")
942                .and_then(|v| v.as_str())
943                .unwrap_or("");
944            let kt = elem.get("KeyType").and_then(|v| v.as_str()).unwrap_or("");
945            format!("KeySchemaElement(attributeName={an}, keyType={kt})")
946        })
947        .collect();
948    format!("[{}]", parts.join(", "))
949}
950
951fn collect_ad_errors(ad_val: &Option<serde_json::Value>, errors: &mut Vec<String>) {
952    match ad_val {
953        None => {
954            errors.push("Value null at 'attributeDefinitions' failed to satisfy constraint: Member must not be null".to_string());
955        }
956        Some(v) => {
957            if let Some(arr) = v.as_array() {
958                for (i, elem) in arr.iter().enumerate() {
959                    if let Some(obj) = elem.as_object() {
960                        if !obj.contains_key("AttributeName")
961                            || obj.get("AttributeName") == Some(&serde_json::Value::Null)
962                        {
963                            errors.push(format!("Value null at 'attributeDefinitions.{}.member.attributeName' failed to satisfy constraint: Member must not be null", i + 1));
964                        }
965                        let at = obj.get("AttributeType");
966                        if at.is_none() || at == Some(&serde_json::Value::Null) {
967                            errors.push(format!("Value null at 'attributeDefinitions.{}.member.attributeType' failed to satisfy constraint: Member must not be null", i + 1));
968                        } else if let Some(s) = at.and_then(|v| v.as_str())
969                            && s != "S"
970                            && s != "N"
971                            && s != "B"
972                        {
973                            errors.push(format!("Value '{}' at 'attributeDefinitions.{}.member.attributeType' failed to satisfy constraint: Member must satisfy enum value set: [B, N, S]", s, i + 1));
974                        }
975                    }
976                }
977            }
978        }
979    }
980}
981
982fn collect_lsi_errors(lsi_val: &Option<serde_json::Value>, errors: &mut Vec<String>) {
983    if let Some(v) = lsi_val
984        && let Some(arr) = v.as_array()
985    {
986        for (i, elem) in arr.iter().enumerate().take(10) {
987            if let Some(obj) = elem.as_object() {
988                // Order: indexName, keySchema, projection
989                if !obj.contains_key("IndexName")
990                    || obj.get("IndexName") == Some(&serde_json::Value::Null)
991                {
992                    errors.push(format!("Value null at 'localSecondaryIndexes.{}.member.indexName' failed to satisfy constraint: Member must not be null", i + 1));
993                } else if let Some(name) = obj.get("IndexName").and_then(|v| v.as_str()) {
994                    collect_idx_name_errors(name, "localSecondaryIndexes", i + 1, errors);
995                }
996                if !obj.contains_key("KeySchema")
997                    || obj.get("KeySchema") == Some(&serde_json::Value::Null)
998                {
999                    errors.push(format!("Value null at 'localSecondaryIndexes.{}.member.keySchema' failed to satisfy constraint: Member must not be null", i + 1));
1000                } else if let Some(ks) = obj.get("KeySchema").and_then(|v| v.as_array())
1001                    && ks.is_empty()
1002                {
1003                    errors.push(format!("Value '[]' at 'localSecondaryIndexes.{}.member.keySchema' failed to satisfy constraint: Member must have length greater than or equal to 1", i + 1));
1004                } else if let Some(ks) = obj.get("KeySchema").and_then(|v| v.as_array())
1005                    && ks.len() > 2
1006                {
1007                    // An LSI key is the table's partition key and one sort key
1008                    // attribute. AWS names the path `keySchema` here, not the
1009                    // index member's (captured eu-west-2, 2026-10-09).
1010                    errors.push(format!("Value '{}' at 'keySchema' failed to satisfy constraint: Member must have length less than or equal to 2", render_key_schema_java_toString(ks)));
1011                }
1012                if !obj.contains_key("Projection")
1013                    || obj.get("Projection") == Some(&serde_json::Value::Null)
1014                {
1015                    errors.push(format!("Value null at 'localSecondaryIndexes.{}.member.projection' failed to satisfy constraint: Member must not be null", i + 1));
1016                } else if let Some(p) = obj.get("Projection").and_then(|v| v.as_object()) {
1017                    collect_proj_errors(
1018                        p,
1019                        &format!("localSecondaryIndexes.{}.member", i + 1),
1020                        errors,
1021                    );
1022                }
1023            }
1024        }
1025    }
1026}
1027
1028fn collect_gsi_errors(gsi_val: &Option<serde_json::Value>, errors: &mut Vec<String>) {
1029    if let Some(v) = gsi_val
1030        && let Some(arr) = v.as_array()
1031    {
1032        for (i, elem) in arr.iter().enumerate().take(10) {
1033            if let Some(obj) = elem.as_object() {
1034                // Order for GSI: keySchema, projection, indexName
1035                if !obj.contains_key("KeySchema")
1036                    || obj.get("KeySchema") == Some(&serde_json::Value::Null)
1037                {
1038                    errors.push(format!("Value null at 'globalSecondaryIndexes.{}.member.keySchema' failed to satisfy constraint: Member must not be null", i + 1));
1039                } else if let Some(ks) = obj.get("KeySchema").and_then(|v| v.as_array())
1040                    && ks.is_empty()
1041                {
1042                    errors.push(format!("Value '[]' at 'globalSecondaryIndexes.{}.member.keySchema' failed to satisfy constraint: Member must have length greater than or equal to 1", i + 1));
1043                } else if let Some(ks) = obj.get("KeySchema").and_then(|v| v.as_array())
1044                    && ks.len() > 8
1045                {
1046                    // Four partition key and four sort key attributes at most.
1047                    // AWS names the path `keySchema` here, as it does for an
1048                    // LSI (captured eu-west-2 and us-east-1, 2026-10-09).
1049                    errors.push(format!("Value '{}' at 'keySchema' failed to satisfy constraint: Member must have length less than or equal to 8", render_key_schema_java_toString(ks)));
1050                }
1051                if !obj.contains_key("Projection")
1052                    || obj.get("Projection") == Some(&serde_json::Value::Null)
1053                {
1054                    errors.push(format!("Value null at 'globalSecondaryIndexes.{}.member.projection' failed to satisfy constraint: Member must not be null", i + 1));
1055                } else if let Some(p) = obj.get("Projection").and_then(|v| v.as_object()) {
1056                    collect_proj_errors(
1057                        p,
1058                        &format!("globalSecondaryIndexes.{}.member", i + 1),
1059                        errors,
1060                    );
1061                }
1062                if !obj.contains_key("IndexName")
1063                    || obj.get("IndexName") == Some(&serde_json::Value::Null)
1064                {
1065                    errors.push(format!("Value null at 'globalSecondaryIndexes.{}.member.indexName' failed to satisfy constraint: Member must not be null", i + 1));
1066                } else if let Some(name) = obj.get("IndexName").and_then(|v| v.as_str()) {
1067                    collect_idx_name_errors(name, "globalSecondaryIndexes", i + 1, errors);
1068                }
1069                // GSI ProvisionedThroughput
1070                if let Some(pt) = obj.get("ProvisionedThroughput").and_then(|v| v.as_object()) {
1071                    let wcu = pt.get("WriteCapacityUnits");
1072                    let rcu = pt.get("ReadCapacityUnits");
1073                    if let Some(w) = wcu.and_then(|v| v.as_i64()) {
1074                        if w < 1 {
1075                            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));
1076                        }
1077                    } else if wcu.is_none() || wcu == Some(&serde_json::Value::Null) {
1078                        errors.push(format!("Value null at 'globalSecondaryIndexes.{}.member.provisionedThroughput.writeCapacityUnits' failed to satisfy constraint: Member must not be null", i + 1));
1079                    }
1080                    if let Some(r) = rcu.and_then(|v| v.as_i64()) {
1081                        if r < 1 {
1082                            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));
1083                        }
1084                    } else if rcu.is_none() || rcu == Some(&serde_json::Value::Null) {
1085                        errors.push(format!("Value null at 'globalSecondaryIndexes.{}.member.provisionedThroughput.readCapacityUnits' failed to satisfy constraint: Member must not be null", i + 1));
1086                    }
1087                }
1088            }
1089        }
1090    }
1091}
1092
1093/// Run the `VectorIndexes` request-model constraints and format the envelope
1094/// DynamoDB answers with, for a caller that does not arrive through the raw
1095/// request's `Deserialize`.
1096///
1097/// The MCP surface builds its request field by field, so without this it skips
1098/// every constraint collected here. Feed it the canonical PascalCase form: the
1099/// collectors read the wire spelling, and MCP names its fields in snake_case,
1100/// so re-serialising the typed value first is what makes the two agree.
1101#[cfg(feature = "mcp-server")]
1102pub(crate) fn vector_indexes_request_model_error(
1103    vix_val: &Option<serde_json::Value>,
1104) -> Option<String> {
1105    let mut errors = Vec::new();
1106    collect_vix_errors(vix_val, &mut errors);
1107    errors.truncate(10);
1108    if errors.is_empty() {
1109        return None;
1110    }
1111    Some(format!(
1112        "{} validation error{} detected: {}",
1113        errors.len(),
1114        if errors.len() == 1 { "" } else { "s" },
1115        errors.join("; ")
1116    ))
1117}
1118
1119/// Request-model constraint errors for `VectorIndexes`, in API-model member
1120/// order: indexName, vectorAttribute, searchSchema, projection, dimensions,
1121/// distanceFunction. The `Dimensions` lower-bound form is captured from real
1122/// DynamoDB (eu-west-2, 2026-08-11); the rest follow the formulaic constraint
1123/// pattern the neighbouring collectors pin.
1124fn collect_vix_errors(vix_val: &Option<serde_json::Value>, errors: &mut Vec<String>) {
1125    let arr = match vix_val.as_ref().and_then(|v| v.as_array()) {
1126        Some(arr) => arr,
1127        None => return,
1128    };
1129    for (i, elem) in arr.iter().enumerate().take(10) {
1130        let obj = match elem.as_object() {
1131            Some(obj) => obj,
1132            None => continue,
1133        };
1134        collect_vix_obj_errors(obj, &format!("vectorIndexes.{}.member", i + 1), errors);
1135    }
1136}
1137
1138/// Request-model constraint errors for one raw vector index object, shared
1139/// with the `UpdateTable` create-action collector: `path` is the object's
1140/// member path (`vectorIndexes.N.member` here,
1141/// `vectorIndexUpdates.N.member.create` there, the latter captured from real
1142/// DynamoDB, eu-west-2 and us-east-1, 2026-08-12).
1143pub(crate) fn collect_vix_obj_errors(
1144    obj: &serde_json::Map<String, serde_json::Value>,
1145    path: &str,
1146    errors: &mut Vec<String>,
1147) {
1148    if !obj.contains_key("IndexName") || obj.get("IndexName") == Some(&serde_json::Value::Null) {
1149        errors.push(format!(
1150            "Value null at '{path}.indexName' failed to satisfy constraint: Member must not be null"
1151        ));
1152    } else if let Some(name) = obj.get("IndexName").and_then(|v| v.as_str()) {
1153        collect_idx_name_errors_at(name, path, errors);
1154    }
1155    if !obj.contains_key("VectorAttribute")
1156        || obj.get("VectorAttribute") == Some(&serde_json::Value::Null)
1157    {
1158        errors.push(format!(
1159            "Value null at '{path}.vectorAttribute' failed to satisfy constraint: Member must not be null"
1160        ));
1161    } else if let Some(va) = obj.get("VectorAttribute").and_then(|v| v.as_object()) {
1162        if !va.contains_key("AttributeName")
1163            || va.get("AttributeName") == Some(&serde_json::Value::Null)
1164        {
1165            errors.push(format!(
1166                "Value null at '{path}.vectorAttribute.attributeName' failed to satisfy constraint: Member must not be null"
1167            ));
1168        } else if let Some(name) = va.get("AttributeName").and_then(|v| v.as_str()) {
1169            // The API model bounds this at one character, which the AWS CLI
1170            // enforces before the request leaves the client. Without it an
1171            // empty name was accepted, the index reported ACTIVE, and no item
1172            // could ever derive a row, so the index stayed empty for good.
1173            if name.is_empty() {
1174                errors.push(format!(
1175                    "Value '' at '{path}.vectorAttribute.attributeName' failed to satisfy constraint: Member must have length greater than or equal to 1"
1176                ));
1177            }
1178        }
1179    }
1180    if let Some(schema) = obj.get("SearchSchema").and_then(|v| v.as_array()) {
1181        for (j, se) in schema.iter().enumerate().take(10) {
1182            let seo = match se.as_object() {
1183                Some(seo) => seo,
1184                None => continue,
1185            };
1186            let sidx = j + 1;
1187            if !seo.contains_key("AttributeName")
1188                || seo.get("AttributeName") == Some(&serde_json::Value::Null)
1189            {
1190                errors.push(format!(
1191                    "Value null at '{path}.searchSchema.{sidx}.member.attributeName' failed to satisfy constraint: Member must not be null"
1192                ));
1193            }
1194            let et = seo.get("SearchSchemaElementType");
1195            if et.is_none() || et == Some(&serde_json::Value::Null) {
1196                errors.push(format!(
1197                    "Value null at '{path}.searchSchema.{sidx}.member.searchSchemaElementType' failed to satisfy constraint: Member must not be null"
1198                ));
1199            } else if let Some(s) = et.and_then(|v| v.as_str())
1200                && s != "HASH"
1201                && s != "INLINE_FILTER"
1202            {
1203                errors.push(format!(
1204                        "Value '{s}' at '{path}.searchSchema.{sidx}.member.searchSchemaElementType' failed to satisfy constraint: Member must satisfy enum value set: [HASH, INLINE_FILTER]"
1205                    ));
1206            }
1207        }
1208    }
1209    if !obj.contains_key("Projection") || obj.get("Projection") == Some(&serde_json::Value::Null) {
1210        errors.push(format!(
1211            "Value null at '{path}.projection' failed to satisfy constraint: Member must not be null"
1212        ));
1213    } else if let Some(p) = obj.get("Projection").and_then(|v| v.as_object()) {
1214        collect_proj_errors(p, path, errors);
1215    }
1216    let dims = obj.get("Dimensions");
1217    if dims.is_none() || dims == Some(&serde_json::Value::Null) {
1218        errors.push(format!(
1219            "Value null at '{path}.dimensions' failed to satisfy constraint: Member must not be null"
1220        ));
1221    } else if let Some(d) = dims.and_then(truncated_dimensions) {
1222        // Fractional values are truncated before validation (captured
1223        // from real DynamoDB, eu-west-2 and us-east-1, 2026-08-12), so
1224        // the lower bound applies to the truncated value; the upper
1225        // bound is enforced later by the typed validator with the bare
1226        // operation-layer message.
1227        if d < 1 {
1228            errors.push(format!(
1229                "Value '{d}' at '{path}.dimensions' failed to satisfy constraint: Member must have value greater than or equal to 1"
1230            ));
1231        }
1232    }
1233    let df = obj.get("DistanceFunction");
1234    if df.is_none() || df == Some(&serde_json::Value::Null) {
1235        errors.push(format!(
1236            "Value null at '{path}.distanceFunction' failed to satisfy constraint: Member must not be null"
1237        ));
1238    } else if let Some(s) = df.and_then(|v| v.as_str())
1239        && s != "COSINE"
1240        && s != "DOT_PRODUCT"
1241        && s != "EUCLIDEAN"
1242    {
1243        errors.push(format!(
1244                "Value '{s}' at '{path}.distanceFunction' failed to satisfy constraint: Member must satisfy enum value set: [COSINE, DOT_PRODUCT, EUCLIDEAN]"
1245            ));
1246    }
1247}
1248
1249/// Effective integer value of a raw `Dimensions` number, truncating any
1250/// fractional part (captured from real DynamoDB, eu-west-2 and us-east-1,
1251/// 2026-08-12: Dimensions 3.5 is accepted and DescribeTable reports 3).
1252/// Values beyond the i64 range saturate; they are far above the 4096 ceiling
1253/// either way. Returns `None` for non-numeric values.
1254fn truncated_dimensions(v: &serde_json::Value) -> Option<i64> {
1255    if let Some(d) = v.as_i64() {
1256        return Some(d);
1257    }
1258    if v.as_u64().is_some() {
1259        // Only reachable for integers above the i64 range.
1260        return Some(i64::MAX);
1261    }
1262    // `as` saturates at the i64 bounds for out-of-range floats.
1263    v.as_f64().map(|f| f.trunc() as i64)
1264}
1265
1266/// Normalise raw `Dimensions` values so the typed `u32` parse cannot fail on
1267/// any numeric input. Fractional values are truncated (see
1268/// [`truncated_dimensions`]); integers above the u32 range clamp to
1269/// `u32::MAX`, which is over the 4096 ceiling, so the typed validator then
1270/// rejects them with the captured over-range message rather than letting a
1271/// raw serde error surface.
1272pub(crate) fn normalise_vix_dimensions(vix_val: &serde_json::Value) -> serde_json::Value {
1273    let mut val = vix_val.clone();
1274    if let Some(arr) = val.as_array_mut() {
1275        for elem in arr {
1276            if let Some(obj) = elem.as_object_mut() {
1277                normalise_vix_dimensions_obj(obj);
1278            }
1279        }
1280    }
1281    val
1282}
1283
1284/// Normalise one raw vector index object's `Dimensions` value in place, shared
1285/// with the `UpdateTable` create-action path so both parse routes apply the
1286/// same truncation and clamping before the typed `u32` parse.
1287pub(crate) fn normalise_vix_dimensions_obj(obj: &mut serde_json::Map<String, serde_json::Value>) {
1288    // The MCP surface names its fields in snake_case, which `VectorIndex`
1289    // accepts by alias, so the normalisation has to recognise both spellings or
1290    // an agent would be refused a value a wire client gets truncated.
1291    let key = if obj.contains_key("Dimensions") {
1292        "Dimensions"
1293    } else {
1294        "dimensions"
1295    };
1296    let Some(dims) = obj.get_mut(key) else {
1297        return;
1298    };
1299    if let Some(d) = truncated_dimensions(dims) {
1300        *dims = serde_json::Value::from(d.clamp(0, i64::from(u32::MAX)) as u32);
1301    }
1302}
1303
1304fn collect_idx_name_errors(name: &str, prefix: &str, idx: usize, errors: &mut Vec<String>) {
1305    collect_idx_name_errors_at(name, &format!("{prefix}.{idx}.member"), errors);
1306}
1307
1308/// As [`collect_idx_name_errors`], but taking the full member path directly,
1309/// for callers whose path does not follow the `<list>.<idx>.member` shape.
1310fn collect_idx_name_errors_at(name: &str, path: &str, errors: &mut Vec<String>) {
1311    if !name
1312        .chars()
1313        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
1314    {
1315        errors.push(format!("Value '{}' at '{}.indexName' failed to satisfy constraint: Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+", name, path));
1316    }
1317    if name.len() < 3 {
1318        errors.push(format!("Value '{}' at '{}.indexName' failed to satisfy constraint: Member must have length greater than or equal to 3", name, path));
1319    }
1320    if name.len() > 255 {
1321        errors.push(format!("Value '{}' at '{}.indexName' failed to satisfy constraint: Member must have length less than or equal to 255", name, path));
1322    }
1323}
1324
1325/// `prefix` is the owning index object's full member path (for example
1326/// `globalSecondaryIndexes.1.member`), so callers with a deeper shape can
1327/// pass their own.
1328fn collect_proj_errors(
1329    proj: &serde_json::Map<String, serde_json::Value>,
1330    prefix: &str,
1331    errors: &mut Vec<String>,
1332) {
1333    if let Some(pt) = proj.get("ProjectionType")
1334        && let Some(s) = pt.as_str()
1335        && s != "ALL"
1336        && s != "KEYS_ONLY"
1337        && s != "INCLUDE"
1338    {
1339        errors.push(format!("Value '{}' at '{}.projection.projectionType' failed to satisfy constraint: Member must satisfy enum value set: [ALL, INCLUDE, KEYS_ONLY]", s, prefix));
1340    }
1341    if let Some(nka) = proj.get("NonKeyAttributes")
1342        && let Some(arr) = nka.as_array()
1343        && arr.is_empty()
1344    {
1345        errors.push(format!("Value '[]' at '{}.projection.nonKeyAttributes' failed to satisfy constraint: Member must have length greater than or equal to 1", prefix));
1346    }
1347}
1348
1349// ---- Structural validation helpers ----
1350
1351fn validate_key_schema_structure(ks: &[KeySchemaElement]) -> std::result::Result<(), String> {
1352    if ks.is_empty() {
1353        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());
1354    }
1355    if ks[0].key_type != KeyType::HASH {
1356        return Err(
1357            "Invalid KeySchema: The first KeySchemaElement is not a HASH key type".to_string(),
1358        );
1359    }
1360    if ks.len() == 2 && ks[0].attribute_name == ks[1].attribute_name {
1361        return Err(
1362            "Both the Hash Key and the Range Key element in the KeySchema have the same name"
1363                .to_string(),
1364        );
1365    }
1366    if ks.len() == 2 && ks[1].key_type != KeyType::RANGE {
1367        return Err(
1368            "Invalid KeySchema: The second KeySchemaElement is not a RANGE key type".to_string(),
1369        );
1370    }
1371    Ok(())
1372}
1373
1374fn validate_key_attrs_in_defs(
1375    ks: &[KeySchemaElement],
1376    defs: &[AttributeDefinition],
1377) -> std::result::Result<(), String> {
1378    // Collect missing key attribute names
1379    let missing: Vec<&str> = ks
1380        .iter()
1381        .filter(|k| !defs.iter().any(|d| d.attribute_name == k.attribute_name))
1382        .map(|k| k.attribute_name.as_str())
1383        .collect();
1384
1385    if missing.is_empty() {
1386        // Even if no keys are missing, check for structural issues (dup names/types)
1387        // which DynamoDB reports as generic "no definition" when defs exist
1388        let has_dup_names = ks.len() == 2 && ks[0].attribute_name == ks[1].attribute_name;
1389        if has_dup_names {
1390            return Err(
1391                "Invalid KeySchema: Some index key attribute have no definition".to_string(),
1392            );
1393        }
1394        return Ok(());
1395    }
1396
1397    // Use generic message when:
1398    // - defs is empty (fewer defs than unique key attrs)
1399    // - key schema has 2 elements (regardless of structural validity)
1400    // - key schema has structural issues (dup names, dup types)
1401    // Use detailed message only when defs is non-empty AND key schema has 1 element
1402    let has_dup_names = ks.len() == 2 && ks[0].attribute_name == ks[1].attribute_name;
1403    let has_dup_types = ks.len() == 2 && ks[0].key_type == ks[1].key_type;
1404    let use_generic = defs.is_empty() || ks.len() >= 2 || has_dup_names || has_dup_types;
1405
1406    if use_generic {
1407        return Err("Invalid KeySchema: Some index key attribute have no definition".to_string());
1408    }
1409
1410    // Detailed message for single-key schema with non-empty defs
1411    let key_names: Vec<&str> = missing.to_vec();
1412    let def_names: Vec<&str> = defs.iter().map(|d| d.attribute_name.as_str()).collect();
1413    Err(format!(
1414        "One or more parameter values were invalid: Some index key attributes are not defined in \
1415         AttributeDefinitions. Keys: [{}], AttributeDefinitions: [{}]",
1416        key_names.join(", "),
1417        def_names.join(", ")
1418    ))
1419}
1420
1421fn validate_attr_def_count(
1422    ks: &[KeySchemaElement],
1423    defs: &[AttributeDefinition],
1424    lsis: &Option<Vec<LocalSecondaryIndex>>,
1425    gsis: &Option<Vec<GlobalSecondaryIndex>>,
1426    vixs: &Option<Vec<VectorIndex>>,
1427) -> std::result::Result<(), String> {
1428    let mut all_key_attrs = std::collections::HashSet::new();
1429    for k in ks {
1430        all_key_attrs.insert(k.attribute_name.as_str());
1431    }
1432    if let Some(lsis) = lsis {
1433        for lsi in lsis {
1434            for k in &lsi.key_schema {
1435                all_key_attrs.insert(k.attribute_name.as_str());
1436            }
1437        }
1438    }
1439    if let Some(gsis) = gsis {
1440        for gsi in gsis {
1441            for k in &gsi.key_schema {
1442                all_key_attrs.insert(k.attribute_name.as_str());
1443            }
1444        }
1445    }
1446    // SearchSchema attributes must be declared in AttributeDefinitions, so
1447    // they join the used set; the vector attribute itself is not a key
1448    // attribute and never counts.
1449    if let Some(vixs) = vixs {
1450        for vix in vixs {
1451            if let Some(ref schema) = vix.search_schema {
1452                for elem in schema {
1453                    all_key_attrs.insert(elem.attribute_name.as_str());
1454                }
1455            }
1456        }
1457    }
1458    if defs.len() != all_key_attrs.len() {
1459        let has_index = lsis.as_ref().is_some_and(|l| !l.is_empty())
1460            || gsis.as_ref().is_some_and(|g| !g.is_empty());
1461        if !has_index {
1462            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());
1463        }
1464        // With an index, both sets are listed, each in the order DynamoDB's own
1465        // hash tables give them. Captured eu-west-2 and us-east-1, 2026-10-09.
1466        let defined: Vec<&str> = defs.iter().map(|d| d.attribute_name.as_str()).collect();
1467        let mut used: Vec<&str> = ks.iter().map(|k| k.attribute_name.as_str()).collect();
1468        for k in lsis.iter().flatten().flat_map(|l| &l.key_schema) {
1469            used.push(k.attribute_name.as_str());
1470        }
1471        for k in gsis.iter().flatten().flat_map(|g| &g.key_schema) {
1472            used.push(k.attribute_name.as_str());
1473        }
1474        for elem in vixs
1475            .iter()
1476            .flatten()
1477            .filter_map(|v| v.search_schema.as_ref())
1478            .flatten()
1479        {
1480            used.push(elem.attribute_name.as_str());
1481        }
1482        return Err(format!(
1483            "One or more parameter values were invalid: Some AttributeDefinitions are not used. \
1484             AttributeDefinitions: [{}], keys used: [{}]",
1485            crate::validation::java_hash_order(&defined, true).join(", "),
1486            crate::validation::java_hash_order(&used, false).join(", ")
1487        ));
1488    }
1489    Ok(())
1490}
1491
1492fn validate_lsi_list(
1493    lsis: &[LocalSecondaryIndex],
1494    ks: &[KeySchemaElement],
1495    defs: &[AttributeDefinition],
1496) -> std::result::Result<(), String> {
1497    // Empty check is done earlier in validate_typed_request
1498
1499    if !ks.iter().any(|k| k.key_type == KeyType::RANGE) {
1500        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());
1501    }
1502
1503    // The first index naming an attribute the request does not define.
1504    for lsi in lsis {
1505        if let Some(message) = crate::validation::undefined_index_key_error(&lsi.key_schema, defs) {
1506            return Err(message);
1507        }
1508    }
1509
1510    // Structural validation for each LSI
1511    for lsi in lsis {
1512        validate_lsi_structure(lsi, ks)?;
1513    }
1514
1515    // Duplicate index names
1516    let mut seen = std::collections::HashSet::new();
1517    for lsi in lsis {
1518        if !seen.insert(&lsi.index_name) {
1519            return Err(format!(
1520                "One or more parameter values were invalid: Duplicate index name: {}",
1521                lsi.index_name
1522            ));
1523        }
1524    }
1525
1526    // Count limit
1527    if lsis.len() > 5 {
1528        return Err("One or more parameter values were invalid: Number of LocalSecondaryIndexes exceeds per-table limit of 5".to_string());
1529    }
1530
1531    Ok(())
1532}
1533
1534fn validate_gsi_list(
1535    gsis: &[GlobalSecondaryIndex],
1536    defs: &[AttributeDefinition],
1537    bm: &str,
1538) -> std::result::Result<(), String> {
1539    // Empty check is done earlier in validate_typed_request
1540
1541    // The first index naming an attribute the request does not define.
1542    for gsi in gsis {
1543        if let Some(message) = crate::validation::undefined_index_key_error(&gsi.key_schema, defs) {
1544            return Err(message);
1545        }
1546    }
1547
1548    // Structural validation for each GSI
1549    for gsi in gsis {
1550        validate_gsi_structure(gsi)?;
1551    }
1552
1553    // Duplicate index names
1554    let mut seen = std::collections::HashSet::new();
1555    for gsi in gsis {
1556        if !seen.insert(&gsi.index_name) {
1557            return Err(format!(
1558                "One or more parameter values were invalid: Duplicate index name: {}",
1559                gsi.index_name
1560            ));
1561        }
1562    }
1563
1564    // Count limit
1565    if gsis.len() > 20 {
1566        return Err("One or more parameter values were invalid: GlobalSecondaryIndex count exceeds the per-table limit of 20".to_string());
1567    }
1568
1569    // PAY_PER_REQUEST billing mode check
1570    if bm == "PAY_PER_REQUEST" {
1571        for gsi in gsis {
1572            if gsi.provisioned_throughput.is_some() {
1573                return Err(format!(
1574                    "One or more parameter values were invalid: ProvisionedThroughput should not be specified for index: {} when BillingMode is PAY_PER_REQUEST",
1575                    gsi.index_name
1576                ));
1577            }
1578        }
1579    }
1580
1581    Ok(())
1582}
1583
1584fn validate_lsi_structure(
1585    lsi: &LocalSecondaryIndex,
1586    table_ks: &[KeySchemaElement],
1587) -> std::result::Result<(), String> {
1588    // Key schema structure first
1589    validate_key_schema_structure(&lsi.key_schema)?;
1590
1591    // Range key presence (before projection, per DynamoDB ordering)
1592    let lsi_sk = lsi.key_schema.iter().find(|k| k.key_type == KeyType::RANGE);
1593    if lsi_sk.is_none() {
1594        return Err(format!(
1595            "One or more parameter values were invalid: Index KeySchema does not have a range key for index: {}",
1596            lsi.index_name
1597        ));
1598    }
1599
1600    // Hash key must match table hash key (before projection)
1601    let table_pk = table_ks
1602        .iter()
1603        .find(|k| k.key_type == KeyType::HASH)
1604        .map(|k| k.attribute_name.as_str());
1605    let lsi_pk = lsi
1606        .key_schema
1607        .iter()
1608        .find(|k| k.key_type == KeyType::HASH)
1609        .map(|k| k.attribute_name.as_str());
1610    if lsi_pk != table_pk {
1611        return Err(format!(
1612            "One or more parameter values were invalid: \
1613             Index KeySchema does not have the same leading hash key as table KeySchema \
1614             for index: {}. index hash key: {}, table hash key: {}",
1615            lsi.index_name,
1616            lsi_pk.unwrap_or("null"),
1617            table_pk.unwrap_or("null")
1618        ));
1619    }
1620
1621    // Projection (after range key and hash key checks)
1622    validate_proj_structure(&lsi.projection)?;
1623
1624    Ok(())
1625}
1626
1627fn validate_gsi_structure(gsi: &GlobalSecondaryIndex) -> std::result::Result<(), String> {
1628    crate::validation::check_gsi_key_schema(&gsi.key_schema)?;
1629    validate_proj_structure(&gsi.projection)?;
1630    Ok(())
1631}
1632
1633fn validate_proj_structure(p: &Projection) -> std::result::Result<(), String> {
1634    // Matched on the projection type regardless of whether NonKeyAttributes is
1635    // present, so the INCLUDE-without-list case (DynamoDB rejects it) is
1636    // reachable. ALL and KEYS_ONLY must not carry a list; INCLUDE requires a
1637    // non-empty one. Reached by both GSI and LSI validation.
1638    match &p.projection_type {
1639        None => Err(
1640            "One or more parameter values were invalid: Unknown ProjectionType: null".to_string(),
1641        ),
1642        Some(ProjectionType::ALL) => match &p.non_key_attributes {
1643            Some(_) => Err("One or more parameter values were invalid: ProjectionType is ALL, but NonKeyAttributes is specified".to_string()),
1644            None => Ok(()),
1645        },
1646        Some(ProjectionType::KEYS_ONLY) => match &p.non_key_attributes {
1647            Some(_) => Err("One or more parameter values were invalid: ProjectionType is KEYS_ONLY, but NonKeyAttributes is specified".to_string()),
1648            None => Ok(()),
1649        },
1650        Some(ProjectionType::INCLUDE) => match &p.non_key_attributes {
1651            None => Err("One or more parameter values were invalid: ProjectionType is INCLUDE, but NonKeyAttributes is not specified".to_string()),
1652            Some(nka) if nka.is_empty() => Err("One or more parameter values were invalid: NonKeyAttributes must not be empty".to_string()),
1653            Some(_) => Ok(()),
1654        },
1655    }
1656}