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    collect_lsi_errors(&raw.local_secondary_indexes, &mut errors);
740    collect_gsi_errors(&raw.global_secondary_indexes, &mut errors);
741    collect_vix_errors(&raw.vector_indexes, &mut errors);
742
743    // DynamoDB caps multi-field constraint errors at 10
744    errors.truncate(10);
745
746    if !errors.is_empty() {
747        let prefix = format!(
748            "{} validation error{} detected: ",
749            errors.len(),
750            if errors.len() == 1 { "" } else { "s" }
751        );
752        return Err(format!("{}{}", prefix, errors.join("; ")));
753    }
754
755    // BillingMode + ProvisionedThroughput consistency (HTTP path only)
756    let billing_mode_str = raw.billing_mode.as_deref().unwrap_or("PROVISIONED");
757    if billing_mode_str == "PAY_PER_REQUEST" && raw.provisioned_throughput.is_some() {
758        return Err(
759            "One or more parameter values were invalid: Neither ReadCapacityUnits nor \
760             WriteCapacityUnits can be specified when BillingMode is PAY_PER_REQUEST"
761                .to_string(),
762        );
763    }
764
765    // ProvisionedThroughput out-of-bounds (after multi-field but before struct checks)
766    if let Some(ref pt) = raw.provisioned_throughput
767        && let Some(obj) = pt.as_object()
768    {
769        let rcu = obj
770            .get("ReadCapacityUnits")
771            .and_then(|v| v.as_i64())
772            .unwrap_or(0);
773        let wcu = obj
774            .get("WriteCapacityUnits")
775            .and_then(|v| v.as_i64())
776            .unwrap_or(0);
777        const MAX_THROUGHPUT: i64 = 1_000_000_000_000;
778        if rcu > MAX_THROUGHPUT {
779            return Err(format!(
780                "Given value {} for ReadCapacityUnits is out of bounds",
781                rcu
782            ));
783        }
784        if wcu > MAX_THROUGHPUT {
785            return Err(format!(
786                "Given value {} for WriteCapacityUnits is out of bounds",
787                wcu
788            ));
789        }
790    }
791
792    // Missing ProvisionedThroughput when BillingMode is explicitly PROVISIONED.
793    if raw.billing_mode.as_deref() == Some("PROVISIONED") && raw.provisioned_throughput.is_none() {
794        return Err(
795            "One or more parameter values were invalid: ReadCapacityUnits and \
796             WriteCapacityUnits must both be specified when BillingMode is PROVISIONED"
797                .to_string(),
798        );
799    }
800
801    // Parse typed structures
802    let key_schema: Vec<KeySchemaElement> = raw
803        .key_schema
804        .as_ref()
805        .map(|v| serde_json::from_value(v.clone()))
806        .transpose()
807        .map_err(|e| e.to_string())?
808        .unwrap_or_default();
809    let attribute_definitions: Vec<AttributeDefinition> = raw
810        .attribute_definitions
811        .as_ref()
812        .map(|v| serde_json::from_value(v.clone()))
813        .transpose()
814        .map_err(|e| e.to_string())?
815        .unwrap_or_default();
816    let provisioned_throughput: Option<ProvisionedThroughput> = raw
817        .provisioned_throughput
818        .as_ref()
819        .map(|v| serde_json::from_value(v.clone()))
820        .transpose()
821        .map_err(|e| e.to_string())?;
822    let global_secondary_indexes: Option<Vec<GlobalSecondaryIndex>> = raw
823        .global_secondary_indexes
824        .as_ref()
825        .map(|v| serde_json::from_value(v.clone()))
826        .transpose()
827        .map_err(|e| e.to_string())?;
828    let local_secondary_indexes: Option<Vec<LocalSecondaryIndex>> = raw
829        .local_secondary_indexes
830        .as_ref()
831        .map(|v| serde_json::from_value(v.clone()))
832        .transpose()
833        .map_err(|e| e.to_string())?;
834    let vector_indexes: Option<Vec<VectorIndex>> = raw
835        .vector_indexes
836        .as_ref()
837        .map(|v| serde_json::from_value(normalise_vix_dimensions(v)))
838        .transpose()
839        .map_err(|e| e.to_string())?;
840
841    Ok(CreateTableRequest {
842        table_name,
843        key_schema,
844        attribute_definitions,
845        global_secondary_indexes,
846        local_secondary_indexes,
847        vector_indexes,
848        billing_mode: raw.billing_mode,
849        provisioned_throughput,
850        stream_specification: raw.stream_specification,
851        sse_specification: raw.sse_specification,
852        table_class: raw.table_class,
853        tags: raw.tags,
854        deletion_protection_enabled: raw.deletion_protection_enabled,
855        on_demand_throughput: raw.on_demand_throughput,
856    })
857}
858
859// ---- Multi-field constraint error collectors ----
860
861fn collect_pt_errors(pt_val: &Option<serde_json::Value>, errors: &mut Vec<String>) {
862    if let Some(v) = pt_val
863        && let Some(obj) = v.as_object()
864    {
865        let wcu = obj.get("WriteCapacityUnits");
866        let rcu = obj.get("ReadCapacityUnits");
867        if wcu.is_none() || wcu == Some(&serde_json::Value::Null) {
868            errors.push("Value null at 'provisionedThroughput.writeCapacityUnits' failed to satisfy constraint: Member must not be null".to_string());
869        } else if let Some(w) = wcu.and_then(|v| v.as_i64())
870            && w < 1
871        {
872            errors.push(format!("Value '{}' at 'provisionedThroughput.writeCapacityUnits' failed to satisfy constraint: Member must have value greater than or equal to 1", w));
873        }
874        if rcu.is_none() || rcu == Some(&serde_json::Value::Null) {
875            errors.push("Value null at 'provisionedThroughput.readCapacityUnits' failed to satisfy constraint: Member must not be null".to_string());
876        } else if let Some(r) = rcu.and_then(|v| v.as_i64())
877            && r < 1
878        {
879            errors.push(format!("Value '{}' at 'provisionedThroughput.readCapacityUnits' failed to satisfy constraint: Member must have value greater than or equal to 1", r));
880        }
881    }
882}
883
884fn collect_ks_errors(ks_val: &Option<serde_json::Value>, errors: &mut Vec<String>) {
885    match ks_val {
886        None => {
887            errors.push(
888                "Value null at 'keySchema' failed to satisfy constraint: Member must not be null"
889                    .to_string(),
890            );
891        }
892        Some(v) => {
893            if let Some(arr) = v.as_array() {
894                if arr.is_empty() {
895                    errors.push("Value '[]' at 'keySchema' failed to satisfy constraint: Member must have length greater than or equal to 1".to_string());
896                } else if arr.len() > 2 {
897                    let dump = render_key_schema_java_toString(arr);
898                    errors.push(format!("Value '{}' at 'keySchema' failed to satisfy constraint: Member must have length less than or equal to 2", dump));
899                }
900                for (i, elem) in arr.iter().enumerate().take(10) {
901                    collect_ks_elem_errors(elem, i + 1, errors);
902                }
903            }
904        }
905    }
906}
907
908fn collect_ks_elem_errors(elem: &serde_json::Value, idx: usize, errors: &mut Vec<String>) {
909    if let Some(obj) = elem.as_object() {
910        if !obj.contains_key("AttributeName")
911            || obj.get("AttributeName") == Some(&serde_json::Value::Null)
912        {
913            errors.push(format!("Value null at 'keySchema.{}.member.attributeName' failed to satisfy constraint: Member must not be null", idx));
914        }
915        let kt = obj.get("KeyType");
916        if kt.is_none() || kt == Some(&serde_json::Value::Null) {
917            errors.push(format!("Value null at 'keySchema.{}.member.keyType' failed to satisfy constraint: Member must not be null", idx));
918        } else if let Some(s) = kt.and_then(|v| v.as_str())
919            && s != "HASH"
920            && s != "RANGE"
921        {
922            errors.push(format!("Value '{}' at 'keySchema.{}.member.keyType' failed to satisfy constraint: Member must satisfy enum value set: [HASH, RANGE]", s, idx));
923        }
924    }
925}
926
927/// Render a KeySchema array the way AWS does in validation messages: Java's
928/// default toString() shape over the SDK's KeySchemaElement model, e.g.
929/// `[KeySchemaElement(attributeName=pk, keyType=HASH), KeySchemaElement(attributeName=sk, keyType=RANGE)]`.
930/// Missing attribute fields render as the empty string, matching what AWS
931/// emits when the upstream object hasn't been populated.
932#[allow(non_snake_case)]
933fn render_key_schema_java_toString(arr: &[serde_json::Value]) -> String {
934    let parts: Vec<String> = arr
935        .iter()
936        .map(|elem| {
937            let an = elem
938                .get("AttributeName")
939                .and_then(|v| v.as_str())
940                .unwrap_or("");
941            let kt = elem.get("KeyType").and_then(|v| v.as_str()).unwrap_or("");
942            format!("KeySchemaElement(attributeName={an}, keyType={kt})")
943        })
944        .collect();
945    format!("[{}]", parts.join(", "))
946}
947
948fn collect_ad_errors(ad_val: &Option<serde_json::Value>, errors: &mut Vec<String>) {
949    match ad_val {
950        None => {
951            errors.push("Value null at 'attributeDefinitions' failed to satisfy constraint: Member must not be null".to_string());
952        }
953        Some(v) => {
954            if let Some(arr) = v.as_array() {
955                for (i, elem) in arr.iter().enumerate() {
956                    if let Some(obj) = elem.as_object() {
957                        if !obj.contains_key("AttributeName")
958                            || obj.get("AttributeName") == Some(&serde_json::Value::Null)
959                        {
960                            errors.push(format!("Value null at 'attributeDefinitions.{}.member.attributeName' failed to satisfy constraint: Member must not be null", i + 1));
961                        }
962                        let at = obj.get("AttributeType");
963                        if at.is_none() || at == Some(&serde_json::Value::Null) {
964                            errors.push(format!("Value null at 'attributeDefinitions.{}.member.attributeType' failed to satisfy constraint: Member must not be null", i + 1));
965                        } else if let Some(s) = at.and_then(|v| v.as_str())
966                            && s != "S"
967                            && s != "N"
968                            && s != "B"
969                        {
970                            errors.push(format!("Value '{}' at 'attributeDefinitions.{}.member.attributeType' failed to satisfy constraint: Member must satisfy enum value set: [B, N, S]", s, i + 1));
971                        }
972                    }
973                }
974            }
975        }
976    }
977}
978
979fn collect_lsi_errors(lsi_val: &Option<serde_json::Value>, errors: &mut Vec<String>) {
980    if let Some(v) = lsi_val
981        && let Some(arr) = v.as_array()
982    {
983        for (i, elem) in arr.iter().enumerate().take(10) {
984            if let Some(obj) = elem.as_object() {
985                // Order: indexName, keySchema, projection
986                if !obj.contains_key("IndexName")
987                    || obj.get("IndexName") == Some(&serde_json::Value::Null)
988                {
989                    errors.push(format!("Value null at 'localSecondaryIndexes.{}.member.indexName' failed to satisfy constraint: Member must not be null", i + 1));
990                } else if let Some(name) = obj.get("IndexName").and_then(|v| v.as_str()) {
991                    collect_idx_name_errors(name, "localSecondaryIndexes", i + 1, errors);
992                }
993                if !obj.contains_key("KeySchema")
994                    || obj.get("KeySchema") == Some(&serde_json::Value::Null)
995                {
996                    errors.push(format!("Value null at 'localSecondaryIndexes.{}.member.keySchema' failed to satisfy constraint: Member must not be null", i + 1));
997                } else if let Some(ks) = obj.get("KeySchema").and_then(|v| v.as_array())
998                    && ks.is_empty()
999                {
1000                    errors.push(format!("Value '[]' at 'localSecondaryIndexes.{}.member.keySchema' failed to satisfy constraint: Member must have length greater than or equal to 1", i + 1));
1001                }
1002                if !obj.contains_key("Projection")
1003                    || obj.get("Projection") == Some(&serde_json::Value::Null)
1004                {
1005                    errors.push(format!("Value null at 'localSecondaryIndexes.{}.member.projection' failed to satisfy constraint: Member must not be null", i + 1));
1006                } else if let Some(p) = obj.get("Projection").and_then(|v| v.as_object()) {
1007                    collect_proj_errors(
1008                        p,
1009                        &format!("localSecondaryIndexes.{}.member", i + 1),
1010                        errors,
1011                    );
1012                }
1013            }
1014        }
1015    }
1016}
1017
1018fn collect_gsi_errors(gsi_val: &Option<serde_json::Value>, errors: &mut Vec<String>) {
1019    if let Some(v) = gsi_val
1020        && let Some(arr) = v.as_array()
1021    {
1022        for (i, elem) in arr.iter().enumerate().take(10) {
1023            if let Some(obj) = elem.as_object() {
1024                // Order for GSI: keySchema, projection, indexName
1025                if !obj.contains_key("KeySchema")
1026                    || obj.get("KeySchema") == Some(&serde_json::Value::Null)
1027                {
1028                    errors.push(format!("Value null at 'globalSecondaryIndexes.{}.member.keySchema' failed to satisfy constraint: Member must not be null", i + 1));
1029                } else if let Some(ks) = obj.get("KeySchema").and_then(|v| v.as_array())
1030                    && ks.is_empty()
1031                {
1032                    errors.push(format!("Value '[]' at 'globalSecondaryIndexes.{}.member.keySchema' failed to satisfy constraint: Member must have length greater than or equal to 1", i + 1));
1033                }
1034                if !obj.contains_key("Projection")
1035                    || obj.get("Projection") == Some(&serde_json::Value::Null)
1036                {
1037                    errors.push(format!("Value null at 'globalSecondaryIndexes.{}.member.projection' failed to satisfy constraint: Member must not be null", i + 1));
1038                } else if let Some(p) = obj.get("Projection").and_then(|v| v.as_object()) {
1039                    collect_proj_errors(
1040                        p,
1041                        &format!("globalSecondaryIndexes.{}.member", i + 1),
1042                        errors,
1043                    );
1044                }
1045                if !obj.contains_key("IndexName")
1046                    || obj.get("IndexName") == Some(&serde_json::Value::Null)
1047                {
1048                    errors.push(format!("Value null at 'globalSecondaryIndexes.{}.member.indexName' failed to satisfy constraint: Member must not be null", i + 1));
1049                } else if let Some(name) = obj.get("IndexName").and_then(|v| v.as_str()) {
1050                    collect_idx_name_errors(name, "globalSecondaryIndexes", i + 1, errors);
1051                }
1052                // GSI ProvisionedThroughput
1053                if let Some(pt) = obj.get("ProvisionedThroughput").and_then(|v| v.as_object()) {
1054                    let wcu = pt.get("WriteCapacityUnits");
1055                    let rcu = pt.get("ReadCapacityUnits");
1056                    if let Some(w) = wcu.and_then(|v| v.as_i64()) {
1057                        if w < 1 {
1058                            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));
1059                        }
1060                    } else if wcu.is_none() || wcu == Some(&serde_json::Value::Null) {
1061                        errors.push(format!("Value null at 'globalSecondaryIndexes.{}.member.provisionedThroughput.writeCapacityUnits' failed to satisfy constraint: Member must not be null", i + 1));
1062                    }
1063                    if let Some(r) = rcu.and_then(|v| v.as_i64()) {
1064                        if r < 1 {
1065                            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));
1066                        }
1067                    } else if rcu.is_none() || rcu == Some(&serde_json::Value::Null) {
1068                        errors.push(format!("Value null at 'globalSecondaryIndexes.{}.member.provisionedThroughput.readCapacityUnits' failed to satisfy constraint: Member must not be null", i + 1));
1069                    }
1070                }
1071            }
1072        }
1073    }
1074}
1075
1076/// Run the `VectorIndexes` request-model constraints and format the envelope
1077/// DynamoDB answers with, for a caller that does not arrive through the raw
1078/// request's `Deserialize`.
1079///
1080/// The MCP surface builds its request field by field, so without this it skips
1081/// every constraint collected here. Feed it the canonical PascalCase form: the
1082/// collectors read the wire spelling, and MCP names its fields in snake_case,
1083/// so re-serialising the typed value first is what makes the two agree.
1084#[cfg(feature = "mcp-server")]
1085pub(crate) fn vector_indexes_request_model_error(
1086    vix_val: &Option<serde_json::Value>,
1087) -> Option<String> {
1088    let mut errors = Vec::new();
1089    collect_vix_errors(vix_val, &mut errors);
1090    errors.truncate(10);
1091    if errors.is_empty() {
1092        return None;
1093    }
1094    Some(format!(
1095        "{} validation error{} detected: {}",
1096        errors.len(),
1097        if errors.len() == 1 { "" } else { "s" },
1098        errors.join("; ")
1099    ))
1100}
1101
1102/// Request-model constraint errors for `VectorIndexes`, in API-model member
1103/// order: indexName, vectorAttribute, searchSchema, projection, dimensions,
1104/// distanceFunction. The `Dimensions` lower-bound form is captured from real
1105/// DynamoDB (eu-west-2, 2026-08-11); the rest follow the formulaic constraint
1106/// pattern the neighbouring collectors pin.
1107fn collect_vix_errors(vix_val: &Option<serde_json::Value>, errors: &mut Vec<String>) {
1108    let arr = match vix_val.as_ref().and_then(|v| v.as_array()) {
1109        Some(arr) => arr,
1110        None => return,
1111    };
1112    for (i, elem) in arr.iter().enumerate().take(10) {
1113        let obj = match elem.as_object() {
1114            Some(obj) => obj,
1115            None => continue,
1116        };
1117        collect_vix_obj_errors(obj, &format!("vectorIndexes.{}.member", i + 1), errors);
1118    }
1119}
1120
1121/// Request-model constraint errors for one raw vector index object, shared
1122/// with the `UpdateTable` create-action collector: `path` is the object's
1123/// member path (`vectorIndexes.N.member` here,
1124/// `vectorIndexUpdates.N.member.create` there, the latter captured from real
1125/// DynamoDB, eu-west-2 and us-east-1, 2026-08-12).
1126pub(crate) fn collect_vix_obj_errors(
1127    obj: &serde_json::Map<String, serde_json::Value>,
1128    path: &str,
1129    errors: &mut Vec<String>,
1130) {
1131    if !obj.contains_key("IndexName") || obj.get("IndexName") == Some(&serde_json::Value::Null) {
1132        errors.push(format!(
1133            "Value null at '{path}.indexName' failed to satisfy constraint: Member must not be null"
1134        ));
1135    } else if let Some(name) = obj.get("IndexName").and_then(|v| v.as_str()) {
1136        collect_idx_name_errors_at(name, path, errors);
1137    }
1138    if !obj.contains_key("VectorAttribute")
1139        || obj.get("VectorAttribute") == Some(&serde_json::Value::Null)
1140    {
1141        errors.push(format!(
1142            "Value null at '{path}.vectorAttribute' failed to satisfy constraint: Member must not be null"
1143        ));
1144    } else if let Some(va) = obj.get("VectorAttribute").and_then(|v| v.as_object()) {
1145        if !va.contains_key("AttributeName")
1146            || va.get("AttributeName") == Some(&serde_json::Value::Null)
1147        {
1148            errors.push(format!(
1149                "Value null at '{path}.vectorAttribute.attributeName' failed to satisfy constraint: Member must not be null"
1150            ));
1151        } else if let Some(name) = va.get("AttributeName").and_then(|v| v.as_str()) {
1152            // The API model bounds this at one character, which the AWS CLI
1153            // enforces before the request leaves the client. Without it an
1154            // empty name was accepted, the index reported ACTIVE, and no item
1155            // could ever derive a row, so the index stayed empty for good.
1156            if name.is_empty() {
1157                errors.push(format!(
1158                    "Value '' at '{path}.vectorAttribute.attributeName' failed to satisfy constraint: Member must have length greater than or equal to 1"
1159                ));
1160            }
1161        }
1162    }
1163    if let Some(schema) = obj.get("SearchSchema").and_then(|v| v.as_array()) {
1164        for (j, se) in schema.iter().enumerate().take(10) {
1165            let seo = match se.as_object() {
1166                Some(seo) => seo,
1167                None => continue,
1168            };
1169            let sidx = j + 1;
1170            if !seo.contains_key("AttributeName")
1171                || seo.get("AttributeName") == Some(&serde_json::Value::Null)
1172            {
1173                errors.push(format!(
1174                    "Value null at '{path}.searchSchema.{sidx}.member.attributeName' failed to satisfy constraint: Member must not be null"
1175                ));
1176            }
1177            let et = seo.get("SearchSchemaElementType");
1178            if et.is_none() || et == Some(&serde_json::Value::Null) {
1179                errors.push(format!(
1180                    "Value null at '{path}.searchSchema.{sidx}.member.searchSchemaElementType' failed to satisfy constraint: Member must not be null"
1181                ));
1182            } else if let Some(s) = et.and_then(|v| v.as_str())
1183                && s != "HASH"
1184                && s != "INLINE_FILTER"
1185            {
1186                errors.push(format!(
1187                        "Value '{s}' at '{path}.searchSchema.{sidx}.member.searchSchemaElementType' failed to satisfy constraint: Member must satisfy enum value set: [HASH, INLINE_FILTER]"
1188                    ));
1189            }
1190        }
1191    }
1192    if !obj.contains_key("Projection") || obj.get("Projection") == Some(&serde_json::Value::Null) {
1193        errors.push(format!(
1194            "Value null at '{path}.projection' failed to satisfy constraint: Member must not be null"
1195        ));
1196    } else if let Some(p) = obj.get("Projection").and_then(|v| v.as_object()) {
1197        collect_proj_errors(p, path, errors);
1198    }
1199    let dims = obj.get("Dimensions");
1200    if dims.is_none() || dims == Some(&serde_json::Value::Null) {
1201        errors.push(format!(
1202            "Value null at '{path}.dimensions' failed to satisfy constraint: Member must not be null"
1203        ));
1204    } else if let Some(d) = dims.and_then(truncated_dimensions) {
1205        // Fractional values are truncated before validation (captured
1206        // from real DynamoDB, eu-west-2 and us-east-1, 2026-08-12), so
1207        // the lower bound applies to the truncated value; the upper
1208        // bound is enforced later by the typed validator with the bare
1209        // operation-layer message.
1210        if d < 1 {
1211            errors.push(format!(
1212                "Value '{d}' at '{path}.dimensions' failed to satisfy constraint: Member must have value greater than or equal to 1"
1213            ));
1214        }
1215    }
1216    let df = obj.get("DistanceFunction");
1217    if df.is_none() || df == Some(&serde_json::Value::Null) {
1218        errors.push(format!(
1219            "Value null at '{path}.distanceFunction' failed to satisfy constraint: Member must not be null"
1220        ));
1221    } else if let Some(s) = df.and_then(|v| v.as_str())
1222        && s != "COSINE"
1223        && s != "DOT_PRODUCT"
1224        && s != "EUCLIDEAN"
1225    {
1226        errors.push(format!(
1227                "Value '{s}' at '{path}.distanceFunction' failed to satisfy constraint: Member must satisfy enum value set: [COSINE, DOT_PRODUCT, EUCLIDEAN]"
1228            ));
1229    }
1230}
1231
1232/// Effective integer value of a raw `Dimensions` number, truncating any
1233/// fractional part (captured from real DynamoDB, eu-west-2 and us-east-1,
1234/// 2026-08-12: Dimensions 3.5 is accepted and DescribeTable reports 3).
1235/// Values beyond the i64 range saturate; they are far above the 4096 ceiling
1236/// either way. Returns `None` for non-numeric values.
1237fn truncated_dimensions(v: &serde_json::Value) -> Option<i64> {
1238    if let Some(d) = v.as_i64() {
1239        return Some(d);
1240    }
1241    if v.as_u64().is_some() {
1242        // Only reachable for integers above the i64 range.
1243        return Some(i64::MAX);
1244    }
1245    // `as` saturates at the i64 bounds for out-of-range floats.
1246    v.as_f64().map(|f| f.trunc() as i64)
1247}
1248
1249/// Normalise raw `Dimensions` values so the typed `u32` parse cannot fail on
1250/// any numeric input. Fractional values are truncated (see
1251/// [`truncated_dimensions`]); integers above the u32 range clamp to
1252/// `u32::MAX`, which is over the 4096 ceiling, so the typed validator then
1253/// rejects them with the captured over-range message rather than letting a
1254/// raw serde error surface.
1255pub(crate) fn normalise_vix_dimensions(vix_val: &serde_json::Value) -> serde_json::Value {
1256    let mut val = vix_val.clone();
1257    if let Some(arr) = val.as_array_mut() {
1258        for elem in arr {
1259            if let Some(obj) = elem.as_object_mut() {
1260                normalise_vix_dimensions_obj(obj);
1261            }
1262        }
1263    }
1264    val
1265}
1266
1267/// Normalise one raw vector index object's `Dimensions` value in place, shared
1268/// with the `UpdateTable` create-action path so both parse routes apply the
1269/// same truncation and clamping before the typed `u32` parse.
1270pub(crate) fn normalise_vix_dimensions_obj(obj: &mut serde_json::Map<String, serde_json::Value>) {
1271    // The MCP surface names its fields in snake_case, which `VectorIndex`
1272    // accepts by alias, so the normalisation has to recognise both spellings or
1273    // an agent would be refused a value a wire client gets truncated.
1274    let key = if obj.contains_key("Dimensions") {
1275        "Dimensions"
1276    } else {
1277        "dimensions"
1278    };
1279    let Some(dims) = obj.get_mut(key) else {
1280        return;
1281    };
1282    if let Some(d) = truncated_dimensions(dims) {
1283        *dims = serde_json::Value::from(d.clamp(0, i64::from(u32::MAX)) as u32);
1284    }
1285}
1286
1287fn collect_idx_name_errors(name: &str, prefix: &str, idx: usize, errors: &mut Vec<String>) {
1288    collect_idx_name_errors_at(name, &format!("{prefix}.{idx}.member"), errors);
1289}
1290
1291/// As [`collect_idx_name_errors`], but taking the full member path directly,
1292/// for callers whose path does not follow the `<list>.<idx>.member` shape.
1293fn collect_idx_name_errors_at(name: &str, path: &str, errors: &mut Vec<String>) {
1294    if !name
1295        .chars()
1296        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
1297    {
1298        errors.push(format!("Value '{}' at '{}.indexName' failed to satisfy constraint: Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+", name, path));
1299    }
1300    if name.len() < 3 {
1301        errors.push(format!("Value '{}' at '{}.indexName' failed to satisfy constraint: Member must have length greater than or equal to 3", name, path));
1302    }
1303    if name.len() > 255 {
1304        errors.push(format!("Value '{}' at '{}.indexName' failed to satisfy constraint: Member must have length less than or equal to 255", name, path));
1305    }
1306}
1307
1308/// `prefix` is the owning index object's full member path (for example
1309/// `globalSecondaryIndexes.1.member`), so callers with a deeper shape can
1310/// pass their own.
1311fn collect_proj_errors(
1312    proj: &serde_json::Map<String, serde_json::Value>,
1313    prefix: &str,
1314    errors: &mut Vec<String>,
1315) {
1316    if let Some(pt) = proj.get("ProjectionType")
1317        && let Some(s) = pt.as_str()
1318        && s != "ALL"
1319        && s != "KEYS_ONLY"
1320        && s != "INCLUDE"
1321    {
1322        errors.push(format!("Value '{}' at '{}.projection.projectionType' failed to satisfy constraint: Member must satisfy enum value set: [ALL, INCLUDE, KEYS_ONLY]", s, prefix));
1323    }
1324    if let Some(nka) = proj.get("NonKeyAttributes")
1325        && let Some(arr) = nka.as_array()
1326        && arr.is_empty()
1327    {
1328        errors.push(format!("Value '[]' at '{}.projection.nonKeyAttributes' failed to satisfy constraint: Member must have length greater than or equal to 1", prefix));
1329    }
1330}
1331
1332// ---- Structural validation helpers ----
1333
1334fn validate_key_schema_structure(ks: &[KeySchemaElement]) -> std::result::Result<(), String> {
1335    if ks.is_empty() {
1336        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());
1337    }
1338    if ks[0].key_type != KeyType::HASH {
1339        return Err(
1340            "Invalid KeySchema: The first KeySchemaElement is not a HASH key type".to_string(),
1341        );
1342    }
1343    if ks.len() == 2 && ks[0].attribute_name == ks[1].attribute_name {
1344        return Err(
1345            "Both the Hash Key and the Range Key element in the KeySchema have the same name"
1346                .to_string(),
1347        );
1348    }
1349    if ks.len() == 2 && ks[1].key_type != KeyType::RANGE {
1350        return Err(
1351            "Invalid KeySchema: The second KeySchemaElement is not a RANGE key type".to_string(),
1352        );
1353    }
1354    Ok(())
1355}
1356
1357fn validate_key_attrs_in_defs(
1358    ks: &[KeySchemaElement],
1359    defs: &[AttributeDefinition],
1360) -> std::result::Result<(), String> {
1361    // Collect missing key attribute names
1362    let missing: Vec<&str> = ks
1363        .iter()
1364        .filter(|k| !defs.iter().any(|d| d.attribute_name == k.attribute_name))
1365        .map(|k| k.attribute_name.as_str())
1366        .collect();
1367
1368    if missing.is_empty() {
1369        // Even if no keys are missing, check for structural issues (dup names/types)
1370        // which DynamoDB reports as generic "no definition" when defs exist
1371        let has_dup_names = ks.len() == 2 && ks[0].attribute_name == ks[1].attribute_name;
1372        if has_dup_names {
1373            return Err(
1374                "Invalid KeySchema: Some index key attribute have no definition".to_string(),
1375            );
1376        }
1377        return Ok(());
1378    }
1379
1380    // Use generic message when:
1381    // - defs is empty (fewer defs than unique key attrs)
1382    // - key schema has 2 elements (regardless of structural validity)
1383    // - key schema has structural issues (dup names, dup types)
1384    // Use detailed message only when defs is non-empty AND key schema has 1 element
1385    let has_dup_names = ks.len() == 2 && ks[0].attribute_name == ks[1].attribute_name;
1386    let has_dup_types = ks.len() == 2 && ks[0].key_type == ks[1].key_type;
1387    let use_generic = defs.is_empty() || ks.len() >= 2 || has_dup_names || has_dup_types;
1388
1389    if use_generic {
1390        return Err("Invalid KeySchema: Some index key attribute have no definition".to_string());
1391    }
1392
1393    // Detailed message for single-key schema with non-empty defs
1394    let key_names: Vec<&str> = missing.to_vec();
1395    let def_names: Vec<&str> = defs.iter().map(|d| d.attribute_name.as_str()).collect();
1396    Err(format!(
1397        "One or more parameter values were invalid: Some index key attributes are not defined in \
1398         AttributeDefinitions. Keys: [{}], AttributeDefinitions: [{}]",
1399        key_names.join(", "),
1400        def_names.join(", ")
1401    ))
1402}
1403
1404fn validate_attr_def_count(
1405    ks: &[KeySchemaElement],
1406    defs: &[AttributeDefinition],
1407    lsis: &Option<Vec<LocalSecondaryIndex>>,
1408    gsis: &Option<Vec<GlobalSecondaryIndex>>,
1409    vixs: &Option<Vec<VectorIndex>>,
1410) -> std::result::Result<(), String> {
1411    let mut all_key_attrs = std::collections::HashSet::new();
1412    for k in ks {
1413        all_key_attrs.insert(k.attribute_name.as_str());
1414    }
1415    if let Some(lsis) = lsis {
1416        for lsi in lsis {
1417            for k in &lsi.key_schema {
1418                all_key_attrs.insert(k.attribute_name.as_str());
1419            }
1420        }
1421    }
1422    if let Some(gsis) = gsis {
1423        for gsi in gsis {
1424            for k in &gsi.key_schema {
1425                all_key_attrs.insert(k.attribute_name.as_str());
1426            }
1427        }
1428    }
1429    // SearchSchema attributes must be declared in AttributeDefinitions, so
1430    // they join the used set; the vector attribute itself is not a key
1431    // attribute and never counts.
1432    if let Some(vixs) = vixs {
1433        for vix in vixs {
1434            if let Some(ref schema) = vix.search_schema {
1435                for elem in schema {
1436                    all_key_attrs.insert(elem.attribute_name.as_str());
1437                }
1438            }
1439        }
1440    }
1441    if defs.len() != all_key_attrs.len() {
1442        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());
1443    }
1444    Ok(())
1445}
1446
1447fn validate_lsi_list(
1448    lsis: &[LocalSecondaryIndex],
1449    ks: &[KeySchemaElement],
1450    defs: &[AttributeDefinition],
1451) -> std::result::Result<(), String> {
1452    // Empty check is done earlier in validate_typed_request
1453
1454    if !ks.iter().any(|k| k.key_type == KeyType::RANGE) {
1455        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());
1456    }
1457
1458    // Check missing attribute definitions across all LSI keys
1459    let def_names: Vec<&str> = defs.iter().map(|d| d.attribute_name.as_str()).collect();
1460    let mut missing_keys = Vec::new();
1461    for lsi in lsis {
1462        for k in &lsi.key_schema {
1463            if !def_names.contains(&k.attribute_name.as_str())
1464                && !missing_keys.contains(&k.attribute_name.as_str())
1465            {
1466                missing_keys.push(k.attribute_name.as_str());
1467            }
1468        }
1469    }
1470    if !missing_keys.is_empty() {
1471        let mut all_keys = Vec::new();
1472        for lsi in lsis {
1473            for k in &lsi.key_schema {
1474                if !all_keys.contains(&k.attribute_name.as_str()) {
1475                    all_keys.push(k.attribute_name.as_str());
1476                }
1477            }
1478        }
1479        return Err(format!(
1480            "One or more parameter values were invalid: Some index key attributes are not defined in AttributeDefinitions. Keys: [{}], AttributeDefinitions: [{}]",
1481            all_keys.join(", "),
1482            def_names.join(", ")
1483        ));
1484    }
1485
1486    // Structural validation for each LSI
1487    for lsi in lsis {
1488        validate_lsi_structure(lsi, ks)?;
1489    }
1490
1491    // Duplicate index names
1492    let mut seen = std::collections::HashSet::new();
1493    for lsi in lsis {
1494        if !seen.insert(&lsi.index_name) {
1495            return Err(format!(
1496                "One or more parameter values were invalid: Duplicate index name: {}",
1497                lsi.index_name
1498            ));
1499        }
1500    }
1501
1502    // Count limit
1503    if lsis.len() > 5 {
1504        return Err("One or more parameter values were invalid: Number of LocalSecondaryIndexes exceeds per-table limit of 5".to_string());
1505    }
1506
1507    Ok(())
1508}
1509
1510fn validate_gsi_list(
1511    gsis: &[GlobalSecondaryIndex],
1512    defs: &[AttributeDefinition],
1513    bm: &str,
1514) -> std::result::Result<(), String> {
1515    // Empty check is done earlier in validate_typed_request
1516
1517    // Check missing attribute definitions across all GSI keys
1518    let def_names: Vec<&str> = defs.iter().map(|d| d.attribute_name.as_str()).collect();
1519    let mut missing_keys = Vec::new();
1520    for gsi in gsis {
1521        for k in &gsi.key_schema {
1522            if !def_names.contains(&k.attribute_name.as_str())
1523                && !missing_keys.contains(&k.attribute_name.as_str())
1524            {
1525                missing_keys.push(k.attribute_name.as_str());
1526            }
1527        }
1528    }
1529    if !missing_keys.is_empty() {
1530        let mut all_keys = Vec::new();
1531        for gsi in gsis {
1532            for k in &gsi.key_schema {
1533                if !all_keys.contains(&k.attribute_name.as_str()) {
1534                    all_keys.push(k.attribute_name.as_str());
1535                }
1536            }
1537        }
1538        return Err(format!(
1539            "One or more parameter values were invalid: Some index key attributes are not defined in AttributeDefinitions. Keys: [{}], AttributeDefinitions: [{}]",
1540            all_keys.join(", "),
1541            def_names.join(", ")
1542        ));
1543    }
1544
1545    // Structural validation for each GSI
1546    for gsi in gsis {
1547        validate_gsi_structure(gsi)?;
1548    }
1549
1550    // Duplicate index names
1551    let mut seen = std::collections::HashSet::new();
1552    for gsi in gsis {
1553        if !seen.insert(&gsi.index_name) {
1554            return Err(format!(
1555                "One or more parameter values were invalid: Duplicate index name: {}",
1556                gsi.index_name
1557            ));
1558        }
1559    }
1560
1561    // Count limit
1562    if gsis.len() > 20 {
1563        return Err("One or more parameter values were invalid: GlobalSecondaryIndex count exceeds the per-table limit of 20".to_string());
1564    }
1565
1566    // PAY_PER_REQUEST billing mode check
1567    if bm == "PAY_PER_REQUEST" {
1568        for gsi in gsis {
1569            if gsi.provisioned_throughput.is_some() {
1570                return Err(format!(
1571                    "One or more parameter values were invalid: ProvisionedThroughput should not be specified for index: {} when BillingMode is PAY_PER_REQUEST",
1572                    gsi.index_name
1573                ));
1574            }
1575        }
1576    }
1577
1578    Ok(())
1579}
1580
1581fn validate_lsi_structure(
1582    lsi: &LocalSecondaryIndex,
1583    table_ks: &[KeySchemaElement],
1584) -> std::result::Result<(), String> {
1585    // Key schema structure first
1586    validate_key_schema_structure(&lsi.key_schema)?;
1587
1588    // Range key presence (before projection, per DynamoDB ordering)
1589    let lsi_sk = lsi.key_schema.iter().find(|k| k.key_type == KeyType::RANGE);
1590    if lsi_sk.is_none() {
1591        return Err(format!(
1592            "One or more parameter values were invalid: Index KeySchema does not have a range key for index: {}",
1593            lsi.index_name
1594        ));
1595    }
1596
1597    // Hash key must match table hash key (before projection)
1598    let table_pk = table_ks
1599        .iter()
1600        .find(|k| k.key_type == KeyType::HASH)
1601        .map(|k| k.attribute_name.as_str());
1602    let lsi_pk = lsi
1603        .key_schema
1604        .iter()
1605        .find(|k| k.key_type == KeyType::HASH)
1606        .map(|k| k.attribute_name.as_str());
1607    if lsi_pk != table_pk {
1608        return Err(format!(
1609            "One or more parameter values were invalid: \
1610             Index KeySchema does not have the same leading hash key as table KeySchema \
1611             for index: {}. index hash key: {}, table hash key: {}",
1612            lsi.index_name,
1613            lsi_pk.unwrap_or("null"),
1614            table_pk.unwrap_or("null")
1615        ));
1616    }
1617
1618    // Projection (after range key and hash key checks)
1619    validate_proj_structure(&lsi.projection)?;
1620
1621    Ok(())
1622}
1623
1624fn validate_gsi_structure(gsi: &GlobalSecondaryIndex) -> std::result::Result<(), String> {
1625    validate_key_schema_structure(&gsi.key_schema)?;
1626    validate_proj_structure(&gsi.projection)?;
1627    Ok(())
1628}
1629
1630fn validate_proj_structure(p: &Projection) -> std::result::Result<(), String> {
1631    // Matched on the projection type regardless of whether NonKeyAttributes is
1632    // present, so the INCLUDE-without-list case (DynamoDB rejects it) is
1633    // reachable. ALL and KEYS_ONLY must not carry a list; INCLUDE requires a
1634    // non-empty one. Reached by both GSI and LSI validation.
1635    match &p.projection_type {
1636        None => Err(
1637            "One or more parameter values were invalid: Unknown ProjectionType: null".to_string(),
1638        ),
1639        Some(ProjectionType::ALL) => match &p.non_key_attributes {
1640            Some(_) => Err("One or more parameter values were invalid: ProjectionType is ALL, but NonKeyAttributes is specified".to_string()),
1641            None => Ok(()),
1642        },
1643        Some(ProjectionType::KEYS_ONLY) => match &p.non_key_attributes {
1644            Some(_) => Err("One or more parameter values were invalid: ProjectionType is KEYS_ONLY, but NonKeyAttributes is specified".to_string()),
1645            None => Ok(()),
1646        },
1647        Some(ProjectionType::INCLUDE) => match &p.non_key_attributes {
1648            None => Err("One or more parameter values were invalid: ProjectionType is INCLUDE, but NonKeyAttributes is not specified".to_string()),
1649            Some(nka) if nka.is_empty() => Err("One or more parameter values were invalid: NonKeyAttributes must not be empty".to_string()),
1650            Some(_) => Ok(()),
1651        },
1652    }
1653}