@typokit/transform-native 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +148 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +290 -0
- package/dist/index.js.map +1 -0
- package/package.json +42 -0
- package/src/env.d.ts +40 -0
- package/src/index.d.ts +135 -0
- package/src/index.test.ts +878 -0
- package/src/index.ts +437 -0
- package/src/lib.rs +388 -0
- package/src/openapi_generator.rs +525 -0
- package/src/output_pipeline.rs +234 -0
- package/src/parser.rs +105 -0
- package/src/route_compiler.rs +615 -0
- package/src/schema_differ.rs +393 -0
- package/src/test_stub_generator.rs +318 -0
- package/src/type_extractor.rs +370 -0
- package/src/typia_bridge.rs +179 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
use std::collections::HashMap;
|
|
2
|
+
use serde::{Deserialize, Serialize};
|
|
3
|
+
use crate::type_extractor::TypeMetadata;
|
|
4
|
+
|
|
5
|
+
/// A single schema change (matches @typokit/types SchemaChange)
|
|
6
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
7
|
+
pub struct SchemaChange {
|
|
8
|
+
#[serde(rename = "type")]
|
|
9
|
+
pub change_type: String, // "add" | "remove" | "modify"
|
|
10
|
+
pub entity: String,
|
|
11
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
12
|
+
pub field: Option<String>,
|
|
13
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
14
|
+
pub details: Option<HashMap<String, String>>,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/// A migration draft (matches @typokit/types MigrationDraft)
|
|
18
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
19
|
+
pub struct MigrationDraft {
|
|
20
|
+
pub name: String,
|
|
21
|
+
pub sql: String,
|
|
22
|
+
pub destructive: bool,
|
|
23
|
+
pub changes: Vec<SchemaChange>,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// Diff two SchemaTypeMap versions and produce a MigrationDraft.
|
|
27
|
+
///
|
|
28
|
+
/// Compares old_types against new_types to detect:
|
|
29
|
+
/// - Added entities (new types not in old)
|
|
30
|
+
/// - Removed entities (old types not in new)
|
|
31
|
+
/// - Added fields (new properties on existing types)
|
|
32
|
+
/// - Removed fields (old properties missing from new types)
|
|
33
|
+
/// - Modified fields (property type changed)
|
|
34
|
+
pub fn diff_schemas(
|
|
35
|
+
old_types: &HashMap<String, TypeMetadata>,
|
|
36
|
+
new_types: &HashMap<String, TypeMetadata>,
|
|
37
|
+
migration_name: &str,
|
|
38
|
+
) -> MigrationDraft {
|
|
39
|
+
let mut changes: Vec<SchemaChange> = Vec::new();
|
|
40
|
+
let mut sql_parts: Vec<String> = Vec::new();
|
|
41
|
+
let mut destructive = false;
|
|
42
|
+
|
|
43
|
+
// Detect added entities
|
|
44
|
+
for (name, meta) in new_types {
|
|
45
|
+
if !old_types.contains_key(name) {
|
|
46
|
+
changes.push(SchemaChange {
|
|
47
|
+
change_type: "add".to_string(),
|
|
48
|
+
entity: name.clone(),
|
|
49
|
+
field: None,
|
|
50
|
+
details: None,
|
|
51
|
+
});
|
|
52
|
+
sql_parts.push(generate_create_table_sql(name, meta));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Detect removed entities
|
|
57
|
+
for name in old_types.keys() {
|
|
58
|
+
if !new_types.contains_key(name) {
|
|
59
|
+
changes.push(SchemaChange {
|
|
60
|
+
change_type: "remove".to_string(),
|
|
61
|
+
entity: name.clone(),
|
|
62
|
+
field: None,
|
|
63
|
+
details: None,
|
|
64
|
+
});
|
|
65
|
+
sql_parts.push(format!(
|
|
66
|
+
"-- DESTRUCTIVE: requires review\nDROP TABLE IF EXISTS \"{}\";",
|
|
67
|
+
to_table_name(name)
|
|
68
|
+
));
|
|
69
|
+
destructive = true;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Detect field-level changes on existing entities
|
|
74
|
+
for (name, new_meta) in new_types {
|
|
75
|
+
if let Some(old_meta) = old_types.get(name) {
|
|
76
|
+
let table = to_table_name(name);
|
|
77
|
+
|
|
78
|
+
// Added fields
|
|
79
|
+
for (field_name, new_prop) in &new_meta.properties {
|
|
80
|
+
if !old_meta.properties.contains_key(field_name) {
|
|
81
|
+
changes.push(SchemaChange {
|
|
82
|
+
change_type: "add".to_string(),
|
|
83
|
+
entity: name.clone(),
|
|
84
|
+
field: Some(field_name.clone()),
|
|
85
|
+
details: None,
|
|
86
|
+
});
|
|
87
|
+
let col_type = ts_type_to_sql(&new_prop.type_str);
|
|
88
|
+
let nullable = if new_prop.optional { "" } else { " NOT NULL" };
|
|
89
|
+
sql_parts.push(format!(
|
|
90
|
+
"ALTER TABLE \"{}\" ADD COLUMN \"{}\" {}{};",
|
|
91
|
+
table, field_name, col_type, nullable
|
|
92
|
+
));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Removed fields
|
|
97
|
+
for field_name in old_meta.properties.keys() {
|
|
98
|
+
if !new_meta.properties.contains_key(field_name) {
|
|
99
|
+
changes.push(SchemaChange {
|
|
100
|
+
change_type: "remove".to_string(),
|
|
101
|
+
entity: name.clone(),
|
|
102
|
+
field: Some(field_name.clone()),
|
|
103
|
+
details: None,
|
|
104
|
+
});
|
|
105
|
+
sql_parts.push(format!(
|
|
106
|
+
"-- DESTRUCTIVE: requires review\nALTER TABLE \"{}\" DROP COLUMN \"{}\";",
|
|
107
|
+
table, field_name
|
|
108
|
+
));
|
|
109
|
+
destructive = true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Modified fields (type changed)
|
|
114
|
+
for (field_name, new_prop) in &new_meta.properties {
|
|
115
|
+
if let Some(old_prop) = old_meta.properties.get(field_name) {
|
|
116
|
+
if old_prop.type_str != new_prop.type_str {
|
|
117
|
+
let mut details = HashMap::new();
|
|
118
|
+
details.insert("oldType".to_string(), old_prop.type_str.clone());
|
|
119
|
+
details.insert("newType".to_string(), new_prop.type_str.clone());
|
|
120
|
+
changes.push(SchemaChange {
|
|
121
|
+
change_type: "modify".to_string(),
|
|
122
|
+
entity: name.clone(),
|
|
123
|
+
field: Some(field_name.clone()),
|
|
124
|
+
details: Some(details),
|
|
125
|
+
});
|
|
126
|
+
let col_type = ts_type_to_sql(&new_prop.type_str);
|
|
127
|
+
sql_parts.push(format!(
|
|
128
|
+
"-- DESTRUCTIVE: requires review\nALTER TABLE \"{}\" ALTER COLUMN \"{}\" TYPE {};",
|
|
129
|
+
table, field_name, col_type
|
|
130
|
+
));
|
|
131
|
+
destructive = true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let sql = if sql_parts.is_empty() {
|
|
139
|
+
"-- No changes detected".to_string()
|
|
140
|
+
} else {
|
|
141
|
+
sql_parts.join("\n\n")
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
MigrationDraft {
|
|
145
|
+
name: migration_name.to_string(),
|
|
146
|
+
sql,
|
|
147
|
+
destructive,
|
|
148
|
+
changes,
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/// Convert a PascalCase type name to a snake_case table name
|
|
153
|
+
fn to_table_name(name: &str) -> String {
|
|
154
|
+
let mut result = String::new();
|
|
155
|
+
for (i, ch) in name.chars().enumerate() {
|
|
156
|
+
if ch.is_uppercase() && i > 0 {
|
|
157
|
+
result.push('_');
|
|
158
|
+
}
|
|
159
|
+
result.push(ch.to_ascii_lowercase());
|
|
160
|
+
}
|
|
161
|
+
result
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/// Map TypeScript type strings to SQL column types
|
|
165
|
+
fn ts_type_to_sql(ts_type: &str) -> String {
|
|
166
|
+
match ts_type {
|
|
167
|
+
"string" => "TEXT".to_string(),
|
|
168
|
+
"number" => "INTEGER".to_string(),
|
|
169
|
+
"boolean" => "BOOLEAN".to_string(),
|
|
170
|
+
"bigint" => "BIGINT".to_string(),
|
|
171
|
+
t if t.ends_with("[]") => "JSONB".to_string(),
|
|
172
|
+
t if t.starts_with("Record<") => "JSONB".to_string(),
|
|
173
|
+
t if t.contains('|') => "TEXT".to_string(), // union types → TEXT
|
|
174
|
+
_ => "TEXT".to_string(), // default fallback
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/// Generate CREATE TABLE SQL from a TypeMetadata
|
|
179
|
+
fn generate_create_table_sql(name: &str, meta: &TypeMetadata) -> String {
|
|
180
|
+
let table = to_table_name(name);
|
|
181
|
+
let mut columns: Vec<String> = Vec::new();
|
|
182
|
+
|
|
183
|
+
// Sort keys for deterministic output
|
|
184
|
+
let mut keys: Vec<&String> = meta.properties.keys().collect();
|
|
185
|
+
keys.sort();
|
|
186
|
+
|
|
187
|
+
for key in keys {
|
|
188
|
+
let prop = &meta.properties[key];
|
|
189
|
+
let col_type = ts_type_to_sql(&prop.type_str);
|
|
190
|
+
let nullable = if prop.optional { "" } else { " NOT NULL" };
|
|
191
|
+
columns.push(format!(" \"{}\" {}{}", key, col_type, nullable));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
format!(
|
|
195
|
+
"CREATE TABLE \"{}\" (\n{}\n);",
|
|
196
|
+
table,
|
|
197
|
+
columns.join(",\n")
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
#[cfg(test)]
|
|
202
|
+
mod tests {
|
|
203
|
+
use super::*;
|
|
204
|
+
use crate::type_extractor::PropertyMetadata;
|
|
205
|
+
|
|
206
|
+
fn make_type(name: &str, props: Vec<(&str, &str, bool)>) -> TypeMetadata {
|
|
207
|
+
let mut properties = HashMap::new();
|
|
208
|
+
for (pname, ptype, optional) in props {
|
|
209
|
+
properties.insert(
|
|
210
|
+
pname.to_string(),
|
|
211
|
+
PropertyMetadata {
|
|
212
|
+
type_str: ptype.to_string(),
|
|
213
|
+
optional,
|
|
214
|
+
jsdoc: None,
|
|
215
|
+
},
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
TypeMetadata {
|
|
219
|
+
name: name.to_string(),
|
|
220
|
+
properties,
|
|
221
|
+
jsdoc: None,
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
#[test]
|
|
226
|
+
fn test_diff_added_entity() {
|
|
227
|
+
let old = HashMap::new();
|
|
228
|
+
let mut new = HashMap::new();
|
|
229
|
+
new.insert("User".to_string(), make_type("User", vec![
|
|
230
|
+
("id", "string", false),
|
|
231
|
+
("name", "string", false),
|
|
232
|
+
]));
|
|
233
|
+
|
|
234
|
+
let draft = diff_schemas(&old, &new, "add_user");
|
|
235
|
+
|
|
236
|
+
assert_eq!(draft.name, "add_user");
|
|
237
|
+
assert!(!draft.destructive);
|
|
238
|
+
assert_eq!(draft.changes.len(), 1);
|
|
239
|
+
assert_eq!(draft.changes[0].change_type, "add");
|
|
240
|
+
assert_eq!(draft.changes[0].entity, "User");
|
|
241
|
+
assert!(draft.changes[0].field.is_none());
|
|
242
|
+
assert!(draft.sql.contains("CREATE TABLE"));
|
|
243
|
+
assert!(draft.sql.contains("user"));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
#[test]
|
|
247
|
+
fn test_diff_removed_entity() {
|
|
248
|
+
let mut old = HashMap::new();
|
|
249
|
+
old.insert("User".to_string(), make_type("User", vec![
|
|
250
|
+
("id", "string", false),
|
|
251
|
+
]));
|
|
252
|
+
let new = HashMap::new();
|
|
253
|
+
|
|
254
|
+
let draft = diff_schemas(&old, &new, "remove_user");
|
|
255
|
+
|
|
256
|
+
assert!(draft.destructive);
|
|
257
|
+
assert_eq!(draft.changes.len(), 1);
|
|
258
|
+
assert_eq!(draft.changes[0].change_type, "remove");
|
|
259
|
+
assert_eq!(draft.changes[0].entity, "User");
|
|
260
|
+
assert!(draft.sql.contains("DROP TABLE"));
|
|
261
|
+
assert!(draft.sql.contains("DESTRUCTIVE"));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
#[test]
|
|
265
|
+
fn test_diff_added_field() {
|
|
266
|
+
let mut old = HashMap::new();
|
|
267
|
+
old.insert("User".to_string(), make_type("User", vec![
|
|
268
|
+
("id", "string", false),
|
|
269
|
+
]));
|
|
270
|
+
let mut new = HashMap::new();
|
|
271
|
+
new.insert("User".to_string(), make_type("User", vec![
|
|
272
|
+
("id", "string", false),
|
|
273
|
+
("email", "string", false),
|
|
274
|
+
]));
|
|
275
|
+
|
|
276
|
+
let draft = diff_schemas(&old, &new, "add_email");
|
|
277
|
+
|
|
278
|
+
assert!(!draft.destructive);
|
|
279
|
+
let add_changes: Vec<_> = draft.changes.iter()
|
|
280
|
+
.filter(|c| c.change_type == "add" && c.field.is_some())
|
|
281
|
+
.collect();
|
|
282
|
+
assert_eq!(add_changes.len(), 1);
|
|
283
|
+
assert_eq!(add_changes[0].field.as_ref().unwrap(), "email");
|
|
284
|
+
assert!(draft.sql.contains("ADD COLUMN"));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
#[test]
|
|
288
|
+
fn test_diff_removed_field() {
|
|
289
|
+
let mut old = HashMap::new();
|
|
290
|
+
old.insert("User".to_string(), make_type("User", vec![
|
|
291
|
+
("id", "string", false),
|
|
292
|
+
("email", "string", false),
|
|
293
|
+
]));
|
|
294
|
+
let mut new = HashMap::new();
|
|
295
|
+
new.insert("User".to_string(), make_type("User", vec![
|
|
296
|
+
("id", "string", false),
|
|
297
|
+
]));
|
|
298
|
+
|
|
299
|
+
let draft = diff_schemas(&old, &new, "remove_email");
|
|
300
|
+
|
|
301
|
+
assert!(draft.destructive);
|
|
302
|
+
let remove_changes: Vec<_> = draft.changes.iter()
|
|
303
|
+
.filter(|c| c.change_type == "remove" && c.field.is_some())
|
|
304
|
+
.collect();
|
|
305
|
+
assert_eq!(remove_changes.len(), 1);
|
|
306
|
+
assert_eq!(remove_changes[0].field.as_ref().unwrap(), "email");
|
|
307
|
+
assert!(draft.sql.contains("DROP COLUMN"));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
#[test]
|
|
311
|
+
fn test_diff_modified_field_type() {
|
|
312
|
+
let mut old = HashMap::new();
|
|
313
|
+
old.insert("User".to_string(), make_type("User", vec![
|
|
314
|
+
("id", "string", false),
|
|
315
|
+
("age", "string", false),
|
|
316
|
+
]));
|
|
317
|
+
let mut new = HashMap::new();
|
|
318
|
+
new.insert("User".to_string(), make_type("User", vec![
|
|
319
|
+
("id", "string", false),
|
|
320
|
+
("age", "number", false),
|
|
321
|
+
]));
|
|
322
|
+
|
|
323
|
+
let draft = diff_schemas(&old, &new, "modify_age");
|
|
324
|
+
|
|
325
|
+
assert!(draft.destructive);
|
|
326
|
+
let modify_changes: Vec<_> = draft.changes.iter()
|
|
327
|
+
.filter(|c| c.change_type == "modify")
|
|
328
|
+
.collect();
|
|
329
|
+
assert_eq!(modify_changes.len(), 1);
|
|
330
|
+
assert_eq!(modify_changes[0].field.as_ref().unwrap(), "age");
|
|
331
|
+
let details = modify_changes[0].details.as_ref().unwrap();
|
|
332
|
+
assert_eq!(details["oldType"], "string");
|
|
333
|
+
assert_eq!(details["newType"], "number");
|
|
334
|
+
assert!(draft.sql.contains("ALTER COLUMN"));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
#[test]
|
|
338
|
+
fn test_diff_no_changes() {
|
|
339
|
+
let mut old = HashMap::new();
|
|
340
|
+
old.insert("User".to_string(), make_type("User", vec![
|
|
341
|
+
("id", "string", false),
|
|
342
|
+
]));
|
|
343
|
+
let new = old.clone();
|
|
344
|
+
|
|
345
|
+
let draft = diff_schemas(&old, &new, "no_changes");
|
|
346
|
+
|
|
347
|
+
assert!(!draft.destructive);
|
|
348
|
+
assert!(draft.changes.is_empty());
|
|
349
|
+
assert!(draft.sql.contains("No changes"));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
#[test]
|
|
353
|
+
fn test_diff_multiple_changes() {
|
|
354
|
+
let mut old = HashMap::new();
|
|
355
|
+
old.insert("User".to_string(), make_type("User", vec![
|
|
356
|
+
("id", "string", false),
|
|
357
|
+
("name", "string", false),
|
|
358
|
+
]));
|
|
359
|
+
|
|
360
|
+
let mut new = HashMap::new();
|
|
361
|
+
new.insert("User".to_string(), make_type("User", vec![
|
|
362
|
+
("id", "string", false),
|
|
363
|
+
("email", "string", false),
|
|
364
|
+
]));
|
|
365
|
+
new.insert("Post".to_string(), make_type("Post", vec![
|
|
366
|
+
("id", "string", false),
|
|
367
|
+
("title", "string", false),
|
|
368
|
+
]));
|
|
369
|
+
|
|
370
|
+
let draft = diff_schemas(&old, &new, "multi_change");
|
|
371
|
+
|
|
372
|
+
// Should have: add Post entity, add email field, remove name field
|
|
373
|
+
assert!(draft.changes.len() >= 3);
|
|
374
|
+
assert!(draft.destructive); // removing 'name' is destructive
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
#[test]
|
|
378
|
+
fn test_to_table_name() {
|
|
379
|
+
assert_eq!(to_table_name("User"), "user");
|
|
380
|
+
assert_eq!(to_table_name("BlogPost"), "blog_post");
|
|
381
|
+
assert_eq!(to_table_name("APIKey"), "a_p_i_key");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
#[test]
|
|
385
|
+
fn test_ts_type_to_sql() {
|
|
386
|
+
assert_eq!(ts_type_to_sql("string"), "TEXT");
|
|
387
|
+
assert_eq!(ts_type_to_sql("number"), "INTEGER");
|
|
388
|
+
assert_eq!(ts_type_to_sql("boolean"), "BOOLEAN");
|
|
389
|
+
assert_eq!(ts_type_to_sql("string[]"), "JSONB");
|
|
390
|
+
assert_eq!(ts_type_to_sql("Record<string, unknown>"), "JSONB");
|
|
391
|
+
assert_eq!(ts_type_to_sql("\"active\" | \"inactive\""), "TEXT");
|
|
392
|
+
}
|
|
393
|
+
}
|