Skip to main content

pylon_core/export/
python_snippet.rs

1//
2// This source file is part of the Pylon open source project.
3//
4// Copyright (c) 2026 Jaldis B.V.
5//
6// Licensed under the MIT OR Apache-2.0 license (the "License");
7// you may not use this file except in compliance with the License.
8// You may obtain a copy of the License at
9//
10//     https://opensource.org/licenses/MIT
11//     https://www.apache.org/licenses/LICENSE-2.0
12//
13// Unless required by applicable law or agreed to in writing, software
14// distributed under the License is distributed on an "AS IS" BASIS,
15// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16// See the License for the specific language governing permissions and
17// limitations under the License.
18//
19
20//! Renders an "equivalent Python schema declaration" snippet for one
21//! `MigrationStep` — shown alongside the DDL preview in the interactive
22//! migration CLI, since Pylon has no separate SDL the way some other
23//! schema-migration tools do, and DDL alone is less immediately readable
24//! than the Python declaration it corresponds to.
25//!
26//! Best-effort and display-only: covers the common cases (scalar/enum
27//! creation, object-type and interface-type creation with the full pointer
28//! list, object-type alteration — which also shows the full current
29//! pointer list, not just the changed ones, since a step's `MigrationStep`
30//! doesn't carry a "before" shape to diff against here — and functions,
31//! shown as their original PyQL body rather than the compiled SQL a
32//! function's DDL preview otherwise shows, since PyQL's compiled form is a
33//! deeply nested CTE tree that reads nothing like what the user actually
34//! wrote) and is not meant to be round-trippable. The actual `.py` schema
35//! file stays authoritative.
36
37use crate::diff::{MigrationStep, OpKey};
38use crate::schema::{EnumDescriptor, FunctionDescriptor, ScalarDescriptor, SchemaDescriptor, TypeDescriptor};
39
40/// `None` for step kinds this renderer doesn't cover yet (modules — see
41/// the module doc comment).
42pub fn python_snippet_for_step(step: &MigrationStep, schema: &SchemaDescriptor) -> Option<String> {
43    match &step.op_key {
44        // A foreign key belongs to a link on the owning type, so its snippet is
45        // that type's own declaration. A junction's FK is keyed by the junction
46        // table (`Type.link`), which matches no type — the lookup yields `None`,
47        // which is the right answer for it.
48        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        // A declared named tuple's type is named for it (`Point` →
71        // `Point_t`, see `schema::tuple_type`), so the declaration is
72        // recoverable. A structural tuple has no declaration of its own —
73        // it exists because some property is typed `pylon.Tuple[...]`, and
74        // that property's type is the thing to show, which this step isn't
75        // about. A rebuild spans a whole set of types, so no single one of
76        // them is the snippet either.
77        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            // A nested structural tuple's own members are a shape, not a
103            // name — `pylon.Tuple[...]` is as far as this goes.
104            TupleMemberKind::Tuple { .. } => "pylon.Tuple[...]".to_string(),
105        };
106        // A positional member has no name to declare; show the index it is
107        // read by, which is what its attribute is called.
108        let name = m.name.clone().unwrap_or_else(|| format!("_{i}"));
109        lines.push(format!("    {name}: {hint}"));
110    }
111    lines.join("\n")
112}
113
114/// Shared by object types (`@pylon.type`) and interface types
115/// (`@pylon.interface`) — same pointer declarations either way.
116fn 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; // implicit primary key, never user-declared
123        }
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
186/// Indents every line of `text` by `indent` — a multi-line PyQL body needs
187/// each of its own lines re-indented under the docstring, not just the
188/// first (its *relative* indentation between lines, e.g. a `with`
189/// binding's continuation, is left untouched — only a uniform baseline is
190/// added).
191fn 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
204/// The trailing unqualified segment of a possibly `module::Name`-qualified
205/// identifier, or of a schema-qualified quoted DDL reference like
206/// `"module"."Name"` once dequoted.
207fn bare_name(qualified: &str) -> &str {
208    qualified.rsplit("::").next().unwrap_or(qualified)
209}
210
211/// Best-effort PostgreSQL base type -> Pylon schema-DSL type hint. Not
212/// exact for every case (arrays of registered scalars, named tuples) —
213/// this is illustrative display only, not a compiler.
214fn python_type_hint(pg_type: &str, column_type: Option<&str>) -> String {
215    if let Some(ct) = column_type {
216        // A registered custom scalar's own DOMAIN reference, schema-qualified
217        // and quoted (e.g. `"module"."Name"`) — show just the bare class name.
218        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        // Enum-typed property, same schema-qualified-and-quoted shape as
229        // a registered scalar's `column_type`.
230        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        // Regression guard: `renders_a_scalar_function_...` above only
477        // exercises `python_snippet_for_step` with a hand-built `MigrationStep`
478        // — this instead goes through the actual
479        // `diff_schema_steps_with_renames_and_fills` entry point the CLI
480        // calls, to catch a mismatch between the OpKey a real diff pass
481        // produces and what this renderer looks up.
482        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}