Skip to content

Commit ac920ef

Browse files
authored
Merge branch 'main' into feat/partiql-delete-returning
2 parents 05d5b73 + 42726a3 commit ac920ef

6 files changed

Lines changed: 464 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
### Added
1515

1616
- PartiQL now honours the `RETURNING` clause on `ExecuteStatement`, where dynoxide previously parsed the statement but silently dropped the clause. `DELETE ... RETURNING ALL OLD *` returns the deleted item in `Items` (a present but empty `Items` array on a missing target, matching DynamoDB rather than the classic `DeleteItem` path), and `UPDATE ... RETURNING <ALL|MODIFIED> <OLD|NEW> *` returns the matching projection of the item; the `MODIFIED` variants return only the changed paths (a nested `SET a.b` returns just the changed leaf, not the whole `a` attribute), exclude the primary key, and return an empty `Items` array when nothing was projected. `BatchExecuteStatement` honours a member's `RETURNING` clause; `ExecuteTransaction` rejects one with a top-level `ValidationException`. The `RETURNING` variants DynamoDB does not allow on `DELETE` (`MODIFIED OLD *`, `ALL NEW *`, `MODIFIED NEW *`) are rejected with its exact validation message instead of being ignored ([#137](https://github.com/nubo-db/dynoxide/issues/137)).
17+
### Added
18+
19+
- `dynoxide serve` and `dynoxide` (no-subcommand) now accept a `--schema` flag, taking the same DynamoDB DescribeTable JSON format as `import --schema`. On startup, dynoxide creates each table defined in the file and skips any that already exist. This lets you pre-populate an empty database — in-memory or persistent — with the correct table structure without running an import first.
1720

1821
## [0.11.4] - 2026-07-17
1922

@@ -423,4 +426,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
423426
[0.9.8]: https://github.com/nubo-db/dynoxide/compare/v0.9.7...v0.9.8
424427
[0.9.7]: https://github.com/nubo-db/dynoxide/compare/v0.9.6...v0.9.7
425428
[0.9.6]: https://github.com/nubo-db/dynoxide/compare/v0.9.5...v0.9.6
426-
[0.9.5]: https://github.com/nubo-db/dynoxide/releases/tag/v0.9.5
429+
[0.9.5]: https://github.com/nubo-db/dynoxide/releases/tag/v0.9.5

docs/http-server.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,16 @@ dynoxide --db-path data.db --encryption-key-file key.hex
2626
DYNOXIDE_ENCRYPTION_KEY=$(cat key.hex) dynoxide --db-path data.db
2727
```
2828

29+
With tables pre-created from a schema:
30+
31+
```sh
32+
# Generate schema
33+
aws dynamodb describe-table --table-name Users > schema.json
34+
35+
# Start with schema
36+
dynoxide --schema schema.json --port 8000
37+
```
38+
2939
Then use the AWS CLI or any DynamoDB SDK pointed at localhost:
3040

3141
```sh

src/import/mod.rs

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,46 @@ pub struct TableImportResult {
101101
pub lines_skipped: usize,
102102
}
103103

104+
/// Scaffold empty tables from a DynamoDB DescribeTable JSON schema file.
105+
///
106+
/// Reads the schema file, creates each table defined in it, and skips any
107+
/// tables that already exist. Returns the number of tables created.
108+
///
109+
/// The schema file format is identical to `import --schema`: a JSON file
110+
/// containing a single `aws dynamodb describe-table` response or an array
111+
/// of them.
112+
pub fn scaffold_from_schema(db: &Database, path: &std::path::Path) -> Result<usize, ImportError> {
113+
let (schemas, schema_json) = schema::load_schemas(path).map_err(ImportError::Config)?;
114+
let mut created = 0;
115+
for table_schema in &schemas {
116+
let create_request = build_create_request(&schema_json, &table_schema.table_name)?;
117+
match db.create_table(create_request) {
118+
Ok(_) => created += 1,
119+
Err(crate::errors::DynoxideError::ResourceInUseException(_)) => {} // already exists
120+
Err(e) => return Err(ImportError::Database(e.to_string())),
121+
}
122+
}
123+
Ok(created)
124+
}
125+
126+
/// Build a `CreateTableRequest` for `table_name` from raw schema JSON.
127+
///
128+
/// Deserializes through `CreateTableRequest`'s `Deserialize` impl (rather than
129+
/// building the struct by hand) so GlobalSecondaryIndexes and
130+
/// LocalSecondaryIndexes are picked up from the schema. Shared by `run_into`
131+
/// and `scaffold_from_schema` so the two can't drift apart on which fields a
132+
/// schema-sourced table ends up with.
133+
fn build_create_request(
134+
schema_json: &serde_json::Value,
135+
table_name: &str,
136+
) -> Result<crate::actions::create_table::CreateTableRequest, String> {
137+
let table_json = find_table_json(schema_json, table_name)
138+
.ok_or_else(|| format!("Schema JSON not found for table '{table_name}'"))?;
139+
140+
serde_json::from_value(table_json)
141+
.map_err(|e| format!("Failed to deserialize schema for '{table_name}': {e}"))
142+
}
143+
104144
/// Execute the import pipeline into a caller-provided database.
105145
///
106146
/// This is the core import logic — database-agnostic. The caller is
@@ -166,13 +206,7 @@ pub fn run_into(db: &Database, cmd: ImportCommand) -> Result<ImportSummary, Impo
166206
)));
167207
}
168208

169-
// Find the matching schema JSON and deserialize into a fresh CreateTableRequest
170-
let table_json = find_table_json(&schema_json, table_name)
171-
.ok_or_else(|| format!("Schema JSON not found for table '{table_name}'"))?;
172-
173-
let create_request: crate::actions::create_table::CreateTableRequest =
174-
serde_json::from_value(table_json)
175-
.map_err(|e| format!("Failed to deserialize schema for '{}': {e}", table_name))?;
209+
let create_request = build_create_request(&schema_json, table_name)?;
176210

177211
db.create_table(create_request)
178212
.map_err(|e| format!("Failed to create table '{}': {e}", table_name))?;

src/main.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ struct Cli {
5454
/// Path to file containing the encryption key (requires encryption feature)
5555
#[arg(long, value_name = "PATH")]
5656
encryption_key_file: Option<PathBuf>,
57+
58+
/// Schema file (JSON with DescribeTable responses) — scaffolds empty tables on startup
59+
#[cfg(feature = "import")]
60+
#[arg(long, value_name = "PATH")]
61+
schema: Option<PathBuf>,
5762
}
5863

5964
#[derive(Subcommand)]
@@ -133,6 +138,11 @@ struct ServeArgs {
133138
#[cfg(feature = "mcp-server")]
134139
#[arg(long, value_name = "HOST", requires = "mcp")]
135140
mcp_allowed_host: Vec<String>,
141+
142+
/// Schema file (JSON with DescribeTable responses) — scaffolds empty tables on startup
143+
#[cfg(feature = "import")]
144+
#[arg(long, value_name = "PATH")]
145+
schema: Option<PathBuf>,
136146
}
137147

138148
/// Arguments for the `healthcheck` subcommand.
@@ -404,6 +414,8 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
404414
mcp_no_auth: false,
405415
#[cfg(feature = "mcp-server")]
406416
mcp_allowed_host: Vec::new(),
417+
#[cfg(feature = "import")]
418+
schema: cli.schema,
407419
};
408420
run_serve(args).await
409421
}
@@ -477,6 +489,13 @@ async fn run_serve(args: ServeArgs) -> Result<(), Box<dyn std::error::Error>> {
477489
}
478490
})?;
479491

492+
#[cfg(feature = "import")]
493+
if let Some(schema_path) = &args.schema {
494+
let n = dynoxide::import::scaffold_from_schema(&db, schema_path)
495+
.map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
496+
eprintln!("Scaffolded {} table(s) from schema", n);
497+
}
498+
480499
#[cfg(feature = "mcp-server")]
481500
if args.mcp {
482501
use tokio_util::sync::CancellationToken;

tests/import.rs

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,172 @@ fields = ["email"]
460460
);
461461
}
462462

463+
// ---------------------------------------------------------------------------
464+
// scaffold_from_schema tests
465+
// ---------------------------------------------------------------------------
466+
467+
#[test]
468+
fn test_scaffold_creates_table() {
469+
let tmp = tempfile::tempdir().unwrap();
470+
let schema_file = tmp.path().join("schema.json");
471+
create_schema_file(&schema_file, &[simple_table_schema("Users")]);
472+
473+
let db = dynoxide::Database::memory().unwrap();
474+
let n = import::scaffold_from_schema(&db, &schema_file).unwrap();
475+
assert_eq!(n, 1);
476+
477+
let tables = db
478+
.list_tables(dynoxide::actions::list_tables::ListTablesRequest::default())
479+
.unwrap();
480+
assert_eq!(tables.table_names, vec!["Users"]);
481+
}
482+
483+
#[test]
484+
fn test_scaffold_multiple_tables() {
485+
let tmp = tempfile::tempdir().unwrap();
486+
let schema_file = tmp.path().join("schema.json");
487+
create_schema_file(
488+
&schema_file,
489+
&[
490+
simple_table_schema("Users"),
491+
simple_table_schema("Orders"),
492+
simple_table_schema("Products"),
493+
],
494+
);
495+
496+
let db = dynoxide::Database::memory().unwrap();
497+
let n = import::scaffold_from_schema(&db, &schema_file).unwrap();
498+
assert_eq!(n, 3);
499+
500+
let mut tables = db
501+
.list_tables(dynoxide::actions::list_tables::ListTablesRequest::default())
502+
.unwrap()
503+
.table_names;
504+
tables.sort();
505+
assert_eq!(tables, vec!["Orders", "Products", "Users"]);
506+
}
507+
508+
#[test]
509+
fn test_scaffold_skips_existing_tables() {
510+
let tmp = tempfile::tempdir().unwrap();
511+
let schema_file = tmp.path().join("schema.json");
512+
create_schema_file(&schema_file, &[simple_table_schema("Users")]);
513+
514+
let db = dynoxide::Database::memory().unwrap();
515+
516+
// First call creates the table.
517+
let n = import::scaffold_from_schema(&db, &schema_file).unwrap();
518+
assert_eq!(n, 1);
519+
520+
// Second call should succeed and skip the already-existing table.
521+
let n = import::scaffold_from_schema(&db, &schema_file).unwrap();
522+
assert_eq!(n, 0);
523+
524+
// Still exactly one table.
525+
let tables = db
526+
.list_tables(dynoxide::actions::list_tables::ListTablesRequest::default())
527+
.unwrap();
528+
assert_eq!(tables.table_names.len(), 1);
529+
}
530+
531+
#[test]
532+
fn test_scaffold_with_gsi() {
533+
let tmp = tempfile::tempdir().unwrap();
534+
let schema_file = tmp.path().join("schema.json");
535+
let schema = serde_json::json!({
536+
"Table": {
537+
"TableName": "Events",
538+
"KeySchema": [
539+
{"AttributeName": "pk", "KeyType": "HASH"},
540+
{"AttributeName": "sk", "KeyType": "RANGE"}
541+
],
542+
"AttributeDefinitions": [
543+
{"AttributeName": "pk", "AttributeType": "S"},
544+
{"AttributeName": "sk", "AttributeType": "S"},
545+
{"AttributeName": "gsi1pk", "AttributeType": "S"}
546+
],
547+
"GlobalSecondaryIndexes": [{
548+
"IndexName": "gsi1",
549+
"KeySchema": [{"AttributeName": "gsi1pk", "KeyType": "HASH"}],
550+
"Projection": {"ProjectionType": "ALL"}
551+
}]
552+
}
553+
});
554+
create_schema_file(&schema_file, &[schema]);
555+
556+
let db = dynoxide::Database::memory().unwrap();
557+
let n = import::scaffold_from_schema(&db, &schema_file).unwrap();
558+
assert_eq!(n, 1);
559+
560+
let info = db
561+
.describe_table(dynoxide::actions::describe_table::DescribeTableRequest {
562+
table_name: "Events".to_string(),
563+
})
564+
.unwrap();
565+
assert!(info.table.global_secondary_indexes.is_some());
566+
assert_eq!(info.table.global_secondary_indexes.unwrap().len(), 1);
567+
}
568+
569+
#[test]
570+
fn test_scaffold_with_lsi() {
571+
// `created_at` is used only by the LSI's sort key, not by the table's
572+
// own key schema. A hand-parsed CreateTableRequest that drops
573+
// LocalSecondaryIndexes leaves it orphaned and table creation fails.
574+
let tmp = tempfile::tempdir().unwrap();
575+
let schema_file = tmp.path().join("schema.json");
576+
let schema = serde_json::json!({
577+
"Table": {
578+
"TableName": "Orders",
579+
"KeySchema": [
580+
{"AttributeName": "pk", "KeyType": "HASH"},
581+
{"AttributeName": "sk", "KeyType": "RANGE"}
582+
],
583+
"AttributeDefinitions": [
584+
{"AttributeName": "pk", "AttributeType": "S"},
585+
{"AttributeName": "sk", "AttributeType": "S"},
586+
{"AttributeName": "created_at", "AttributeType": "S"}
587+
],
588+
"LocalSecondaryIndexes": [{
589+
"IndexName": "by-created-at",
590+
"KeySchema": [
591+
{"AttributeName": "pk", "KeyType": "HASH"},
592+
{"AttributeName": "created_at", "KeyType": "RANGE"}
593+
],
594+
"Projection": {"ProjectionType": "ALL"}
595+
}]
596+
}
597+
});
598+
create_schema_file(&schema_file, &[schema]);
599+
600+
let db = dynoxide::Database::memory().unwrap();
601+
let n = import::scaffold_from_schema(&db, &schema_file).unwrap();
602+
assert_eq!(n, 1);
603+
604+
let info = db
605+
.describe_table(dynoxide::actions::describe_table::DescribeTableRequest {
606+
table_name: "Orders".to_string(),
607+
})
608+
.unwrap();
609+
assert!(info.table.local_secondary_indexes.is_some());
610+
assert_eq!(info.table.local_secondary_indexes.unwrap().len(), 1);
611+
}
612+
613+
#[test]
614+
fn test_scaffold_missing_file_errors() {
615+
let db = dynoxide::Database::memory().unwrap();
616+
let result = import::scaffold_from_schema(
617+
&db,
618+
std::path::Path::new("/tmp/dynoxide-nonexistent-schema-file.json"),
619+
);
620+
assert!(result.is_err());
621+
assert!(
622+
result
623+
.unwrap_err()
624+
.to_string()
625+
.contains("Failed to read schema file")
626+
);
627+
}
628+
463629
#[test]
464630
fn test_import_into_memory_database() {
465631
let tmp = tempfile::tempdir().unwrap();

0 commit comments

Comments
 (0)