@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.
@@ -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
+ }