Skip to main content

plsql_catalog/
synthetic.rs

1//! Synthetic test catalog builder for creating realistic test fixtures.
2
3use std::collections::HashMap;
4
5use chrono::Utc;
6use plsql_core::{ColumnName, ObjectName, RoleName, SchemaName, SymbolId, SymbolInterner};
7
8use crate::{
9    AccessibleByTarget, ArgumentMetadata, CatalogCapabilities, CatalogObject, CatalogSnapshot,
10    CatalogSource, CatalogSourceKind, ColumnMetadata, DataTypeRef, Grant, GrantPrivilege, Grantee,
11    ObjectCommon, ObjectStatus, ObjectType, PackageMetadata, ProcedureMetadata, RoutineSignature,
12    SchemaCatalog, SequenceMetadata, SynonymTarget, TableMetadata, TriggerEvent, TriggerLevel,
13    TriggerMetadata, TriggerTiming, ViewMetadata,
14};
15
16/// Builder for constructing a synthetic `CatalogSnapshot` for testing.
17#[derive(Debug)]
18pub struct SyntheticCatalogBuilder {
19    interner: SymbolInterner,
20    schemas: HashMap<SchemaName, SchemaCatalog>,
21    current_schema: SchemaName,
22}
23
24impl SyntheticCatalogBuilder {
25    /// Create a new builder with a default schema.
26    pub fn new(schema_name: &str) -> Self {
27        let mut interner = SymbolInterner::default();
28        let schema = SchemaName::new(interner.intern(schema_name).unwrap());
29        let mut schemas = HashMap::new();
30        schemas.insert(schema, SchemaCatalog::default());
31
32        Self {
33            interner,
34            schemas,
35            current_schema: schema,
36        }
37    }
38
39    /// Get the current schema name.
40    pub fn current_schema(&self) -> SchemaName {
41        self.current_schema
42    }
43
44    /// Get a reference to the symbol interner.
45    pub fn interner(&self) -> &SymbolInterner {
46        &self.interner
47    }
48
49    /// Intern a string and return the symbol ID.
50    fn intern(&mut self, s: &str) -> SymbolId {
51        self.interner.intern(s).unwrap()
52    }
53
54    /// Add a second schema to the snapshot.
55    pub fn add_schema(&mut self, name: &str) -> SchemaName {
56        let schema = SchemaName::new(self.intern(name));
57        self.schemas.insert(schema, SchemaCatalog::default());
58        schema
59    }
60
61    /// Add a table to the current schema.
62    pub fn add_table(&mut self, name: &str, columns: Vec<(&str, &str, bool)>) -> ObjectName {
63        let obj_name = ObjectName::new(self.intern(name));
64        let col_map: HashMap<ColumnName, ColumnMetadata> = columns
65            .into_iter()
66            .enumerate()
67            .map(|(i, (col_name, data_type, nullable))| {
68                let cn = ColumnName::new(self.intern(col_name));
69                (
70                    cn,
71                    ColumnMetadata {
72                        name: cn,
73                        position: i as u32 + 1,
74                        data_type: DataTypeRef {
75                            name: data_type.to_string(),
76                            ..DataTypeRef::default()
77                        },
78                        nullable,
79                        ..ColumnMetadata::default()
80                    },
81                )
82            })
83            .collect();
84
85        let table = TableMetadata {
86            common: ObjectCommon {
87                owner: self.current_schema,
88                name: obj_name,
89                object_type: ObjectType::Table,
90                status: ObjectStatus::Valid,
91                ..ObjectCommon::default()
92            },
93            columns: col_map,
94            ..TableMetadata::default()
95        };
96
97        self.current_schema_catalog_mut()
98            .objects
99            .insert(obj_name, CatalogObject::Table(table));
100        obj_name
101    }
102
103    /// Add a view to the current schema.
104    pub fn add_view(&mut self, name: &str, columns: Vec<(&str, &str, bool)>) -> ObjectName {
105        let obj_name = ObjectName::new(self.intern(name));
106        let col_map: HashMap<ColumnName, ColumnMetadata> = columns
107            .into_iter()
108            .enumerate()
109            .map(|(i, (col_name, data_type, nullable))| {
110                let cn = ColumnName::new(self.intern(col_name));
111                (
112                    cn,
113                    ColumnMetadata {
114                        name: cn,
115                        position: i as u32 + 1,
116                        data_type: DataTypeRef {
117                            name: data_type.to_string(),
118                            ..DataTypeRef::default()
119                        },
120                        nullable,
121                        ..ColumnMetadata::default()
122                    },
123                )
124            })
125            .collect();
126
127        let view = ViewMetadata {
128            common: ObjectCommon {
129                owner: self.current_schema,
130                name: obj_name,
131                object_type: ObjectType::View,
132                status: ObjectStatus::Valid,
133                ..ObjectCommon::default()
134            },
135            columns: col_map,
136            ..ViewMetadata::default()
137        };
138
139        self.current_schema_catalog_mut()
140            .objects
141            .insert(obj_name, CatalogObject::View(view));
142        obj_name
143    }
144
145    /// Add a package to the current schema with optional procedures and functions.
146    pub fn add_package(
147        &mut self,
148        name: &str,
149        invoker_rights: bool,
150        accessible_by: Vec<(&str, &str)>,
151    ) -> ObjectName {
152        let obj_name = ObjectName::new(self.intern(name));
153
154        let access_list: Vec<AccessibleByTarget> = accessible_by
155            .into_iter()
156            .map(|(owner, obj)| AccessibleByTarget {
157                owner: Some(SchemaName::new(self.intern(owner))),
158                object_name: ObjectName::new(self.intern(obj)),
159            })
160            .collect();
161
162        let pkg = PackageMetadata {
163            common: ObjectCommon {
164                owner: self.current_schema,
165                name: obj_name,
166                object_type: ObjectType::Package,
167                status: ObjectStatus::Valid,
168                ..ObjectCommon::default()
169            },
170            authid_current_user: Some(invoker_rights),
171            accessible_by: access_list,
172            ..PackageMetadata::default()
173        };
174
175        self.current_schema_catalog_mut()
176            .objects
177            .insert(obj_name, CatalogObject::Package(pkg));
178        obj_name
179    }
180
181    /// Add a standalone procedure to the current schema.
182    pub fn add_procedure(
183        &mut self,
184        name: &str,
185        invoker_rights: bool,
186        args: Vec<ArgumentMetadata>,
187    ) -> ObjectName {
188        let obj_name = ObjectName::new(self.intern(name));
189
190        let proc = ProcedureMetadata {
191            common: ObjectCommon {
192                owner: self.current_schema,
193                name: obj_name,
194                object_type: ObjectType::Procedure,
195                status: ObjectStatus::Valid,
196                ..ObjectCommon::default()
197            },
198            signature: RoutineSignature {
199                routine_name: obj_name,
200                authid_current_user: Some(invoker_rights),
201                arguments: args,
202                ..RoutineSignature::default()
203            },
204        };
205
206        self.current_schema_catalog_mut()
207            .objects
208            .insert(obj_name, CatalogObject::Procedure(proc));
209        obj_name
210    }
211
212    /// Add a sequence to the current schema.
213    pub fn add_sequence(&mut self, name: &str) -> ObjectName {
214        let obj_name = ObjectName::new(self.intern(name));
215
216        let seq = SequenceMetadata {
217            common: ObjectCommon {
218                owner: self.current_schema,
219                name: obj_name,
220                object_type: ObjectType::Sequence,
221                status: ObjectStatus::Valid,
222                ..ObjectCommon::default()
223            },
224            ..SequenceMetadata::default()
225        };
226
227        self.current_schema_catalog_mut()
228            .objects
229            .insert(obj_name, CatalogObject::Sequence(seq));
230        obj_name
231    }
232
233    /// Add a trigger to the current schema.
234    pub fn add_trigger(
235        &mut self,
236        name: &str,
237        table_name: &str,
238        event: TriggerEvent,
239        timing: TriggerTiming,
240        level: TriggerLevel,
241    ) -> ObjectName {
242        let obj_name = ObjectName::new(self.intern(name));
243        let tbl = ObjectName::new(self.intern(table_name));
244
245        let trigger = TriggerMetadata {
246            common: ObjectCommon {
247                owner: self.current_schema,
248                name: obj_name,
249                object_type: ObjectType::Trigger,
250                status: ObjectStatus::Valid,
251                ..ObjectCommon::default()
252            },
253            target_owner: self.current_schema,
254            target_name: tbl,
255            events: vec![event],
256            timing,
257            level,
258            ..TriggerMetadata::default()
259        };
260
261        self.current_schema_catalog_mut()
262            .objects
263            .insert(obj_name, CatalogObject::Trigger(trigger));
264        obj_name
265    }
266
267    /// Add a grant to the current schema.
268    pub fn add_grant(
269        &mut self,
270        object_name: ObjectName,
271        privilege: GrantPrivilege,
272        grantee: Grantee,
273        grantable: bool,
274    ) {
275        let owner = self.current_schema;
276        self.current_schema_catalog_mut().grants.push(Grant {
277            object_owner: owner,
278            object_name,
279            privilege,
280            grantee,
281            grantable,
282            via_role: None,
283            with_hierarchy: false,
284        });
285    }
286
287    /// Add a synonym in the current schema.
288    pub fn add_synonym(
289        &mut self,
290        name: &str,
291        target_schema: SchemaName,
292        target_name: &str,
293        public: bool,
294    ) {
295        let syn_id = self.intern(name);
296        let _syn_name = ObjectName::new(syn_id);
297        let target_obj = ObjectName::new(self.intern(target_name));
298        let _owner = self.current_schema;
299
300        self.current_schema_catalog_mut().synonyms.insert(
301            crate::SynonymName::new(syn_id),
302            SynonymTarget {
303                target_owner: Some(target_schema),
304                target_name: target_obj,
305                target_type: None,
306                db_link: None,
307                public_synonym: public,
308            },
309        );
310    }
311
312    /// Build the final `CatalogSnapshot`.
313    pub fn build(self) -> CatalogSnapshot {
314        CatalogSnapshot {
315            schemas: self.schemas,
316            profile: plsql_core::AnalysisProfile::default(),
317            capabilities: CatalogCapabilities {
318                can_query_all_views: true,
319                can_query_dba_views: false,
320                can_use_dbms_metadata: false,
321                can_read_source: true,
322                plscope_enabled: false,
323                can_query_scheduler: false,
324                can_query_roles_and_grants: true,
325                warnings: vec![],
326            },
327            generated_at: Utc::now(),
328            source: CatalogSource {
329                kind: CatalogSourceKind::SyntheticTestCatalog,
330                description: Some("Synthetic test catalog".to_string()),
331                ..CatalogSource::default()
332            },
333            interner: self.interner,
334            editions: Vec::new(),
335            // Synthetic catalogs construct Grantee values directly, so they
336            // never route through ALL_USERS-based grantee classification.
337            known_users: None,
338        }
339    }
340
341    fn current_schema_catalog_mut(&mut self) -> &mut SchemaCatalog {
342        self.schemas.get_mut(&self.current_schema).unwrap()
343    }
344}
345
346/// Create a minimal billing schema for testing — the canonical hero demo estate.
347///
348/// Tables: customers, invoices, invoice_lines, payments
349/// Packages: billing_api, payment_processor
350/// Views: v_customer_balance
351/// Grants: reader/reader roles
352pub fn billing_schema() -> CatalogSnapshot {
353    let mut builder = SyntheticCatalogBuilder::new("BILLING");
354
355    // Tables
356    let customers = builder.add_table(
357        "CUSTOMERS",
358        vec![
359            ("CUSTOMER_ID", "NUMBER", false),
360            ("NAME", "VARCHAR2", false),
361            ("EMAIL", "VARCHAR2", true),
362            ("LEGACY_SEGMENT", "VARCHAR2", true),
363            ("STATUS", "VARCHAR2", false),
364        ],
365    );
366
367    let invoices = builder.add_table(
368        "INVOICES",
369        vec![
370            ("INVOICE_ID", "NUMBER", false),
371            ("CUSTOMER_ID", "NUMBER", false),
372            ("AMOUNT", "NUMBER", false),
373            ("STATUS", "VARCHAR2", false),
374            ("CREATED_DATE", "DATE", false),
375        ],
376    );
377
378    let invoice_lines = builder.add_table(
379        "INVOICE_LINES",
380        vec![
381            ("LINE_ID", "NUMBER", false),
382            ("INVOICE_ID", "NUMBER", false),
383            ("DESCRIPTION", "VARCHAR2", false),
384            ("QTY", "NUMBER", false),
385            ("UNIT_PRICE", "NUMBER", false),
386        ],
387    );
388
389    let _payments = builder.add_table(
390        "PAYMENTS",
391        vec![
392            ("PAYMENT_ID", "NUMBER", false),
393            ("INVOICE_ID", "NUMBER", false),
394            ("AMOUNT", "NUMBER", false),
395            ("PAYMENT_DATE", "DATE", false),
396        ],
397    );
398
399    // Views
400    let _balance_view = builder.add_view(
401        "V_CUSTOMER_BALANCE",
402        vec![
403            ("CUSTOMER_ID", "NUMBER", false),
404            ("NAME", "VARCHAR2", false),
405            ("TOTAL_INVOICED", "NUMBER", true),
406            ("TOTAL_PAID", "NUMBER", true),
407            ("BALANCE", "NUMBER", true),
408        ],
409    );
410
411    // Packages
412    let billing_api = builder.add_package("BILLING_API", false, vec![]);
413    let _payment_proc =
414        builder.add_package("PAYMENT_PROCESSOR", true, vec![("BILLING", "BILLING_API")]);
415
416    // Standalone procedure
417    builder.add_procedure("GENERATE_INVOICE", false, vec![]);
418
419    // Sequence
420    builder.add_sequence("INVOICE_SEQ");
421
422    // Grants
423    let reader_role = RoleName::new(builder.intern("reader"));
424    builder.add_grant(
425        customers,
426        GrantPrivilege::Select,
427        Grantee::Role(reader_role),
428        false,
429    );
430    builder.add_grant(
431        invoices,
432        GrantPrivilege::Select,
433        Grantee::Role(reader_role),
434        false,
435    );
436    builder.add_grant(
437        invoice_lines,
438        GrantPrivilege::Select,
439        Grantee::Role(reader_role),
440        false,
441    );
442    builder.add_grant(billing_api, GrantPrivilege::Execute, Grantee::Public, false);
443
444    builder.build()
445}