1use crate::diff::{MigrationStep, OpKey};
38use crate::schema::{EnumDescriptor, FunctionDescriptor, ScalarDescriptor, SchemaDescriptor, TypeDescriptor};
39
40pub fn python_snippet_for_step(step: &MigrationStep, schema: &SchemaDescriptor) -> Option<String> {
43 match &step.op_key {
44 OpKey::Table(module, table) | OpKey::ForeignKey(module, table) => {
49 let td = schema.types.iter().find(|t| &t.module == module && &t.table == table)?;
50 Some(python_snippet_for_class(td, "@pylon.type"))
51 }
52 OpKey::View(module, name) => {
53 let td = schema.types.iter().find(|t| &t.module == module && &t.name == name)?;
54 Some(python_snippet_for_class(td, "@pylon.interface"))
55 }
56 OpKey::Scalar(module, name) => {
57 if let Some(e) = schema.enums.iter().find(|e| &e.module == module && &e.name == name) {
58 return Some(python_snippet_for_enum(e));
59 }
60 let s = schema.scalars.iter().find(|s| &s.module == module && &s.name == name)?;
61 Some(python_snippet_for_scalar(s))
62 }
63 OpKey::Function(module, name) => {
64 let f = schema
65 .functions
66 .iter()
67 .find(|f| &f.module == module && &f.name == name)?;
68 Some(python_snippet_for_function(f))
69 }
70 OpKey::TupleType(pg_schema, name) => {
78 let module = if pg_schema == "public" { "default" } else { pg_schema };
79 let declared = name.strip_suffix("_t")?;
80 let nt = schema
81 .named_tuples
82 .iter()
83 .find(|nt| nt.module == module && nt.name == declared)?;
84 Some(python_snippet_for_named_tuple(nt))
85 }
86 OpKey::TupleTypeRebuild | OpKey::Module(_) => None,
87 }
88}
89
90fn python_snippet_for_named_tuple(nt: &crate::schema::NamedTupleDescriptor) -> String {
91 use crate::schema::TupleMemberKind;
92
93 let mut lines = vec!["@pylon.named_tuple".to_string(), format!("class {}:", nt.name)];
94 if nt.members.is_empty() {
95 lines.push(" pass".to_string());
96 return lines.join("\n");
97 }
98 for (i, m) in nt.members.iter().enumerate() {
99 let hint = match &m.kind {
100 TupleMemberKind::Scalar { pg_type } => python_type_hint(pg_type, None),
101 TupleMemberKind::Enum { name, .. } | TupleMemberKind::NamedTuple { name, .. } => name.clone(),
102 TupleMemberKind::Tuple { .. } => "pylon.Tuple[...]".to_string(),
105 };
106 let name = m.name.clone().unwrap_or_else(|| format!("_{i}"));
109 lines.push(format!(" {name}: {hint}"));
110 }
111 lines.join("\n")
112}
113
114fn python_snippet_for_class(td: &TypeDescriptor, decorator: &str) -> String {
117 let mut lines = vec![decorator.to_string(), format!("class {}:", td.name)];
118 let mut body: Vec<String> = Vec::new();
119
120 for p in &td.properties {
121 if p.name == "id" {
122 continue; }
124 let hint = python_type_hint(&p.pg_type, p.column_type.as_deref());
125 let hint = if p.nullable { format!("{hint} | None") } else { hint };
126 body.push(format!(" {}: {}", p.name, hint));
127 }
128 for l in &td.links {
129 let hint = format!("Link[{}]", bare_name(&l.target));
130 let hint = if l.nullable { format!("{hint} | None") } else { hint };
131 body.push(format!(" {}: {}", l.name, hint));
132 }
133 for ml in &td.multilinks {
134 body.push(format!(" {}: MultiLink[{}]", ml.name, bare_name(&ml.target)));
135 }
136
137 if body.is_empty() {
138 lines.push(" pass".to_string());
139 } else {
140 lines.extend(body);
141 }
142 lines.join("\n")
143}
144
145fn python_snippet_for_enum(e: &EnumDescriptor) -> String {
146 let members: Vec<String> = e.members.iter().map(|m| format!("\"{m}\"")).collect();
147 format!(
148 "@pylon.enum({})\nclass {}(pylon.Enum):\n pass",
149 members.join(", "),
150 e.name
151 )
152}
153
154fn python_snippet_for_scalar(s: &ScalarDescriptor) -> String {
155 format!(
156 "@pylon.scalar(pylon.{})\nclass {}(pylon.Scalar):\n pass",
157 s.base, s.name
158 )
159}
160
161fn python_snippet_for_function(f: &FunctionDescriptor) -> String {
162 let params: Vec<String> = f
163 .params
164 .iter()
165 .map(|p| format!("{}: {}", p.name, python_type_hint(&p.pg_type, None)))
166 .collect();
167
168 let mut return_hint = if f.return_is_object {
169 bare_name(&f.return_pg_type).to_string()
170 } else {
171 python_type_hint(&f.return_pg_type, None)
172 };
173 if f.return_is_set {
174 return_hint = format!("set[{return_hint}]");
175 }
176
177 format!(
178 "@pylon.function\ndef {}({}) -> {}:\n \"\"\"\n{}\n \"\"\"",
179 f.name,
180 params.join(", "),
181 return_hint,
182 indent_block(f.body.trim(), " "),
183 )
184}
185
186fn indent_block(text: &str, indent: &str) -> String {
192 text.lines()
193 .map(|line| {
194 if line.is_empty() {
195 line.to_string()
196 } else {
197 format!("{indent}{line}")
198 }
199 })
200 .collect::<Vec<_>>()
201 .join("\n")
202}
203
204fn bare_name(qualified: &str) -> &str {
208 qualified.rsplit("::").next().unwrap_or(qualified)
209}
210
211fn python_type_hint(pg_type: &str, column_type: Option<&str>) -> String {
215 if let Some(ct) = column_type {
216 let dequoted = ct.replace("\".\"", "::").replace('"', "");
219 return bare_name(&dequoted).to_string();
220 }
221 if let Some(elem) = pg_type.strip_suffix("[]") {
222 return format!("list[{}]", python_type_hint(elem, None));
223 }
224 if let Some(nt_name) = pg_type.strip_prefix("__nt__:") {
225 return bare_name(nt_name).to_string();
226 }
227 if pg_type.starts_with('"') {
228 let dequoted = pg_type.replace("\".\"", "::").replace('"', "");
231 return bare_name(&dequoted).to_string();
232 }
233 match pg_type {
234 "text" | "varchar" => "str".to_string(),
235 "boolean" => "bool".to_string(),
236 "float8" => "float".to_string(),
237 "int4" => "int".to_string(),
238 "int2" => "pylon.Int16".to_string(),
239 "int8" => "pylon.Int64".to_string(),
240 "float4" => "pylon.Float32".to_string(),
241 "numeric" => "pylon.Decimal".to_string(),
242 "timestamptz" => "pylon.DateTime".to_string(),
243 "timestamp" => "pylon.LocalDateTime".to_string(),
244 "date" => "pylon.LocalDate".to_string(),
245 "time" => "pylon.LocalTime".to_string(),
246 "uuid" => "pylon.UUID".to_string(),
247 "bytea" => "pylon.Bytes".to_string(),
248 "jsonb" => "pylon.Json".to_string(),
249 "interval" => "pylon.Duration".to_string(),
250 other => other.to_string(),
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257 use crate::diff::Verb;
258
259 fn type_step(td: TypeDescriptor, verb: Verb) -> MigrationStep {
260 MigrationStep {
261 prompt: String::new(),
262 verb,
263 object_desc: String::new(),
264 ddl: vec![],
265 required_input: vec![],
266 op_key: OpKey::Table(td.module.clone(), td.table.clone()),
267 }
268 }
269
270 fn empty_type(module: &str, name: &str, table: &str) -> TypeDescriptor {
271 TypeDescriptor {
272 name: name.into(),
273 module: module.into(),
274 table: table.into(),
275 abstract_: false,
276 materialized: false,
277 description: None,
278 parents: vec![],
279 interfaces: vec![],
280 bases: vec![],
281 properties: vec![],
282 links: vec![],
283 multilinks: vec![],
284 computed: vec![],
285 constraints: vec![],
286 indexes: vec![],
287 partition: None,
288 vector_indexes: vec![],
289 search_indexes: vec![],
290 triggers: vec![],
291 junction: false,
292 signals: vec![],
293 }
294 }
295
296 fn prop(name: &str, pg_type: &str, nullable: bool) -> crate::schema::PropertyDescriptor {
297 crate::schema::PropertyDescriptor {
298 name: name.into(),
299 pg_type: pg_type.into(),
300 nullable,
301 default_sql: None,
302 default_pyql: None,
303 description: None,
304 check_constraints: vec![],
305 is_exclusive: false,
306 is_pk: name == "id",
307 is_readonly: name == "id",
308 rewrites: vec![],
309 tuple_members: None,
310 column_type: None,
311 }
312 }
313
314 #[test]
315 fn renders_a_simple_object_type() {
316 let mut td = empty_type("blog", "Post", "Post");
317 td.properties.push(prop("id", "uuid", false));
318 td.properties.push(prop("title", "text", false));
319 td.properties.push(prop("views", "int8", true));
320 let schema = SchemaDescriptor {
321 types: vec![td],
322 scalars: vec![],
323 enums: vec![],
324 named_tuples: vec![],
325 globals: vec![],
326 functions: vec![],
327 aliases: vec![],
328 channels: vec![],
329 ..Default::default()
330 };
331 let step = type_step(schema.types[0].clone(), Verb::Create);
332 let snippet = python_snippet_for_step(&step, &schema).unwrap();
333 assert_eq!(
334 snippet,
335 "@pylon.type\nclass Post:\n title: str\n views: pylon.Int64 | None"
336 );
337 }
338
339 #[test]
340 fn renders_an_interface_type_as_pylon_interface_not_the_view_sql() {
341 let mut td = empty_type("default", "Account", "Account");
342 td.abstract_ = true;
343 td.materialized = true;
344 td.properties.push(prop("id", "uuid", false));
345 td.properties.push(prop("email", "text", false));
346 let schema = SchemaDescriptor {
347 types: vec![td],
348 scalars: vec![],
349 enums: vec![],
350 named_tuples: vec![],
351 globals: vec![],
352 functions: vec![],
353 aliases: vec![],
354 channels: vec![],
355 ..Default::default()
356 };
357 let step = MigrationStep {
358 prompt: String::new(),
359 verb: Verb::Create,
360 object_desc: String::new(),
361 ddl: vec![],
362 required_input: vec![],
363 op_key: OpKey::View("default".into(), "Account".into()),
364 };
365 let snippet = python_snippet_for_step(&step, &schema).unwrap();
366 assert_eq!(snippet, "@pylon.interface\nclass Account:\n email: str");
367 }
368
369 #[test]
370 fn renders_an_enum() {
371 let schema = SchemaDescriptor {
372 types: vec![],
373 scalars: vec![],
374 enums: vec![EnumDescriptor {
375 name: "Status".into(),
376 module: "default".into(),
377 members: vec!["Active".into(), "Inactive".into()],
378 }],
379 named_tuples: vec![],
380 globals: vec![],
381 functions: vec![],
382 aliases: vec![],
383 channels: vec![],
384 ..Default::default()
385 };
386 let step = MigrationStep {
387 prompt: String::new(),
388 verb: Verb::Create,
389 object_desc: String::new(),
390 ddl: vec![],
391 required_input: vec![],
392 op_key: OpKey::Scalar("default".into(), "Status".into()),
393 };
394 let snippet = python_snippet_for_step(&step, &schema).unwrap();
395 assert_eq!(
396 snippet,
397 "@pylon.enum(\"Active\", \"Inactive\")\nclass Status(pylon.Enum):\n pass"
398 );
399 }
400
401 #[test]
402 fn renders_a_scalar_function_as_its_pyql_body_not_compiled_sql() {
403 let schema = SchemaDescriptor {
404 types: vec![],
405 scalars: vec![],
406 enums: vec![],
407 named_tuples: vec![],
408 globals: vec![],
409 aliases: vec![],
410 channels: vec![],
411 functions: vec![FunctionDescriptor {
412 name: "get_content_type".into(),
413 module: "default".into(),
414 params: vec![crate::schema::FunctionParamDescriptor {
415 name: "uuid_val".into(),
416 pg_type: "uuid".into(),
417 }],
418 return_pg_type: "int2".into(),
419 return_is_object: false,
420 return_is_set: false,
421 return_is_polymorphic: false,
422 volatility: "immutable".into(),
423 body: "select 1".into(),
424 }],
425 ..Default::default()
426 };
427 let step = MigrationStep {
428 prompt: String::new(),
429 verb: Verb::Create,
430 object_desc: String::new(),
431 ddl: vec![],
432 required_input: vec![],
433 op_key: OpKey::Function("default".into(), "get_content_type".into()),
434 };
435 let snippet = python_snippet_for_step(&step, &schema).unwrap();
436 assert_eq!(
437 snippet,
438 "@pylon.function\ndef get_content_type(uuid_val: pylon.UUID) -> pylon.Int16:\n \"\"\"\n select 1\n \"\"\""
439 );
440 }
441
442 #[test]
443 fn indents_every_line_of_a_multiline_pyql_body() {
444 let schema = SchemaDescriptor {
445 types: vec![], scalars: vec![], enums: vec![], named_tuples: vec![], globals: vec![], aliases: vec![], channels: vec![],
446 functions: vec![FunctionDescriptor {
447 name: "get_content_type".into(),
448 module: "default".into(),
449 params: vec![crate::schema::FunctionParamDescriptor { name: "uuid_val".into(), pg_type: "uuid".into() }],
450 return_pg_type: "int8".into(),
451 return_is_object: false,
452 return_is_set: false,
453 return_is_polymorphic: false,
454 volatility: "immutable".into(),
455 body: "with\n h := str_replace(<str>uuid_val, '-', ''),\n ct_bytes := std::from_hex(h[20:24])\nselect ct_bytes".into(),
456 }],
457 ..Default::default()
458 };
459 let step = MigrationStep {
460 prompt: String::new(),
461 verb: Verb::Create,
462 object_desc: String::new(),
463 ddl: vec![],
464 required_input: vec![],
465 op_key: OpKey::Function("default".into(), "get_content_type".into()),
466 };
467 let snippet = python_snippet_for_step(&step, &schema).unwrap();
468 assert_eq!(
469 snippet,
470 "@pylon.function\ndef get_content_type(uuid_val: pylon.UUID) -> pylon.Int64:\n \"\"\"\n with\n h := str_replace(<str>uuid_val, '-', ''),\n ct_bytes := std::from_hex(h[20:24])\n select ct_bytes\n \"\"\""
471 );
472 }
473
474 #[test]
475 fn function_snippet_survives_the_real_diff_pipeline() {
476 let schema = SchemaDescriptor {
483 types: vec![],
484 scalars: vec![],
485 enums: vec![],
486 named_tuples: vec![],
487 globals: vec![],
488 aliases: vec![],
489 channels: vec![],
490 functions: vec![FunctionDescriptor {
491 name: "get_content_type".into(),
492 module: "default".into(),
493 params: vec![crate::schema::FunctionParamDescriptor {
494 name: "uuid_val".into(),
495 pg_type: "uuid".into(),
496 }],
497 return_pg_type: "int2".into(),
498 return_is_object: false,
499 return_is_set: false,
500 return_is_polymorphic: false,
501 volatility: "immutable".into(),
502 body: "select 1".into(),
503 }],
504 ..Default::default()
505 };
506 let steps = crate::diff::diff_schema_steps_with_renames_and_fills(
507 &schema,
508 &crate::diff::DbState::default(),
509 &[],
510 &[],
511 &[],
512 )
513 .unwrap();
514 let step = steps
515 .iter()
516 .find(|s| s.prompt.contains("get_content_type"))
517 .expect("expected a step for get_content_type");
518 let snippet = python_snippet_for_step(step, &schema);
519 assert!(
520 snippet.is_some(),
521 "expected a snippet for the function step, prompt was: {:?}",
522 step.prompt
523 );
524 }
525
526 #[test]
527 fn returns_none_for_a_module_step() {
528 let schema = SchemaDescriptor {
529 types: vec![],
530 scalars: vec![],
531 enums: vec![],
532 named_tuples: vec![],
533 globals: vec![],
534 functions: vec![],
535 aliases: vec![],
536 channels: vec![],
537 ..Default::default()
538 };
539 let step = MigrationStep {
540 prompt: String::new(),
541 verb: Verb::Create,
542 object_desc: String::new(),
543 ddl: vec![],
544 required_input: vec![],
545 op_key: OpKey::Module("catalog".into()),
546 };
547 assert!(python_snippet_for_step(&step, &schema).is_none());
548 }
549}