Skip to main content

plsql_catalog/
lib.rs

1#![forbid(unsafe_code)]
2pub mod synthetic;
3
4use std::collections::{BTreeMap, HashMap, HashSet};
5use std::fs;
6use std::path::PathBuf;
7
8use asupersync::Cx;
9use chrono::{DateTime, Utc};
10use plsql_core::{
11    AnalysisProfile, ColumnName, EditionName, MemberName, ObjectName, OracleVersion, RoleName,
12    SchemaName, SymbolId, SymbolInterner, UserName,
13};
14use serde::{Deserialize, Serialize};
15use thiserror::Error;
16use tracing::instrument;
17
18use plsql_output::SchemaVersion;
19
20macro_rules! catalog_name {
21    ($name:ident) => {
22        #[derive(
23            Clone,
24            Copy,
25            Debug,
26            Default,
27            Eq,
28            PartialEq,
29            Ord,
30            PartialOrd,
31            Hash,
32            Serialize,
33            Deserialize,
34        )]
35        #[serde(transparent)]
36        pub struct $name(SymbolId);
37
38        impl $name {
39            #[must_use]
40            #[instrument(level = "trace")]
41            pub fn new(symbol: SymbolId) -> Self {
42                Self(symbol)
43            }
44
45            #[must_use]
46            #[instrument(level = "trace", skip(self))]
47            pub fn symbol(self) -> SymbolId {
48                self.0
49            }
50        }
51
52        impl From<SymbolId> for $name {
53            fn from(value: SymbolId) -> Self {
54                Self::new(value)
55            }
56        }
57    };
58}
59
60catalog_name!(SynonymName);
61catalog_name!(IndexName);
62catalog_name!(ConstraintName);
63catalog_name!(TriggerName);
64
65#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
66#[serde(transparent)]
67pub struct Hash(String);
68
69impl Hash {
70    #[must_use]
71    #[instrument(level = "trace", skip(value))]
72    pub fn new(value: impl Into<String>) -> Self {
73        Self(value.into())
74    }
75
76    #[must_use]
77    #[instrument(level = "trace", skip(self))]
78    pub fn as_str(&self) -> &str {
79        self.0.as_str()
80    }
81}
82
83#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
84pub struct DbmsMetadataDdl {
85    pub ddl_text: String,
86    pub normalized_ddl: Option<String>,
87    pub xml_text: Option<String>,
88}
89
90#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
91pub enum CatalogSourceKind {
92    #[default]
93    JsonSnapshot,
94    LiveConnection,
95    DbmsMetadataFiles,
96    SyntheticTestCatalog,
97}
98
99#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
100pub struct CatalogSource {
101    pub kind: CatalogSourceKind,
102    pub path: Option<PathBuf>,
103    pub description: Option<String>,
104}
105
106#[derive(
107    Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
108)]
109pub enum ObjectType {
110    Table,
111    View,
112    MaterializedView,
113    Sequence,
114    Type,
115    Package,
116    Procedure,
117    Function,
118    Trigger,
119    SchedulerJob,
120    EditioningView,
121    Synonym,
122    Index,
123    Constraint,
124    #[default]
125    Unknown,
126}
127
128#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
129pub enum ObjectStatus {
130    Valid,
131    Invalid,
132    #[default]
133    NotApplicable,
134}
135
136#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
137pub struct ObjectCommon {
138    pub owner: SchemaName,
139    pub name: ObjectName,
140    pub object_type: ObjectType,
141    pub status: ObjectStatus,
142    pub edition_name: Option<EditionName>,
143    pub editionable: Option<bool>,
144    pub last_ddl_time: Option<DateTime<Utc>>,
145    pub source_hash: Option<Hash>,
146    pub ddl: Option<DbmsMetadataDdl>,
147}
148
149#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
150pub struct CatalogCapabilities {
151    pub can_query_dba_views: bool,
152    pub can_query_all_views: bool,
153    pub can_use_dbms_metadata: bool,
154    pub can_read_source: bool,
155    pub plscope_enabled: bool,
156    pub can_query_scheduler: bool,
157    pub can_query_roles_and_grants: bool,
158    pub warnings: Vec<CapabilityWarning>,
159}
160
161#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
162pub struct CapabilityWarning {
163    pub code: String,
164    pub message: String,
165    pub remediation: Option<String>,
166}
167
168#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
169pub struct CatalogSnapshot {
170    pub schemas: HashMap<SchemaName, SchemaCatalog>,
171    pub profile: AnalysisProfile,
172    pub capabilities: CatalogCapabilities,
173    pub generated_at: DateTime<Utc>,
174    pub source: CatalogSource,
175    pub interner: SymbolInterner,
176    /// Database-wide edition tree from `ALL_EDITIONS`. Empty when EBR
177    /// is not in use.
178    #[serde(default)]
179    pub editions: Vec<Edition>,
180    /// Set of database usernames observed from `ALL_USERS`, used during
181    /// live extraction to discriminate an object-privilege grantee
182    /// (`ALL_TAB_PRIVS.GRANTEE`) between a real user and a database role —
183    /// Oracle's `ALL_TAB_PRIVS` carries no user/role discriminator column.
184    ///
185    /// `None` means the username set was never loaded (the `ALL_USERS`
186    /// probe was not run or failed); in that state grantee classification
187    /// is *undetermined* and, honoring R13, the extractor must NOT
188    /// silently assume a direct (high-confidence) user grant. `Some(_)`
189    /// (even when empty) means the set was loaded and is authoritative for
190    /// the schemas under analysis.
191    ///
192    /// This is transient extraction state: it is never serialized, because
193    /// the resulting `Grantee` discrimination is already baked into each
194    /// persisted `Grant`. JSON snapshots therefore round-trip unchanged.
195    #[serde(default, skip)]
196    pub known_users: Option<HashSet<UserName>>,
197}
198
199impl CatalogSnapshot {
200    #[must_use]
201    #[instrument(level = "trace", skip(profile, capabilities, source))]
202    pub fn new(
203        profile: AnalysisProfile,
204        capabilities: CatalogCapabilities,
205        source: CatalogSource,
206        generated_at: DateTime<Utc>,
207    ) -> Self {
208        Self {
209            schemas: HashMap::new(),
210            profile,
211            capabilities,
212            generated_at,
213            source,
214            interner: SymbolInterner::new(),
215            editions: Vec::new(),
216            known_users: None,
217        }
218    }
219
220    /// Intern `text` as a [`UserName`] without changing classification
221    /// state. Mirrors [`SymbolInterner::intern_user_name`] but routes
222    /// through this snapshot's interner.
223    #[must_use]
224    #[instrument(level = "trace", skip(self, text))]
225    pub fn intern_user_name(&mut self, text: impl Into<String>) -> Option<UserName> {
226        self.interner.intern_user_name(text)
227    }
228
229    /// Intern `text` as a [`RoleName`] through this snapshot's interner.
230    #[must_use]
231    #[instrument(level = "trace", skip(self, text))]
232    pub fn intern_role_name(&mut self, text: impl Into<String>) -> Option<RoleName> {
233        self.interner.intern_role_name(text)
234    }
235
236    #[must_use]
237    #[instrument(level = "trace", skip(self, text))]
238    pub fn intern_schema_name(&mut self, text: impl Into<String>) -> Option<SchemaName> {
239        self.interner.intern_schema_name(text)
240    }
241
242    #[must_use]
243    #[instrument(level = "trace", skip(self, text))]
244    pub fn intern_object_name(&mut self, text: impl Into<String>) -> Option<ObjectName> {
245        self.interner.intern(text).map(ObjectName::from)
246    }
247
248    #[must_use]
249    #[instrument(level = "trace", skip(self, text))]
250    pub fn intern_column_name(&mut self, text: impl Into<String>) -> Option<ColumnName> {
251        self.interner.intern(text).map(ColumnName::from)
252    }
253
254    #[must_use]
255    #[instrument(level = "trace", skip(self, text))]
256    pub fn intern_member_name(&mut self, text: impl Into<String>) -> Option<MemberName> {
257        self.interner.intern(text).map(MemberName::from)
258    }
259
260    #[must_use]
261    #[instrument(level = "trace", skip(self, text))]
262    pub fn intern_synonym_name(&mut self, text: impl Into<String>) -> Option<SynonymName> {
263        self.interner.intern(text).map(SynonymName::from)
264    }
265
266    #[must_use]
267    #[instrument(level = "trace", skip(self, text))]
268    pub fn intern_index_name(&mut self, text: impl Into<String>) -> Option<IndexName> {
269        self.interner.intern(text).map(IndexName::from)
270    }
271
272    #[must_use]
273    #[instrument(level = "trace", skip(self, text))]
274    pub fn intern_constraint_name(&mut self, text: impl Into<String>) -> Option<ConstraintName> {
275        self.interner.intern(text).map(ConstraintName::from)
276    }
277
278    #[must_use]
279    #[instrument(level = "trace", skip(self, text))]
280    pub fn intern_trigger_name(&mut self, text: impl Into<String>) -> Option<TriggerName> {
281        self.interner.intern(text).map(TriggerName::from)
282    }
283}
284
285pub const CATALOG_SNAPSHOT_SCHEMA_ID: &str = "plsql.catalog.snapshot";
286pub const CATALOG_SNAPSHOT_SCHEMA_VERSION: SchemaVersion = SchemaVersion::new(1, 1, 0);
287
288pub const CATALOG_DOCTOR_SCHEMA_ID: &str = "plsql.catalog.doctor";
289pub const CATALOG_DOCTOR_SCHEMA_VERSION: SchemaVersion = SchemaVersion::new(1, 0, 0);
290
291/// Per-`ObjectType` count tile shown in the doctor report.
292#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
293pub struct DoctorObjectCount {
294    pub object_type: ObjectType,
295    pub total: usize,
296    pub valid: usize,
297    pub invalid: usize,
298    pub other: usize,
299}
300
301/// Summary of how many catalog rows landed per family and how many
302/// schema-scoped buckets are populated.
303#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
304pub struct DoctorExtractionTotals {
305    pub schemas_observed: usize,
306    pub objects_total: usize,
307    pub columns_total: usize,
308    pub indexes_total: usize,
309    pub constraints_total: usize,
310    pub triggers_total: usize,
311    pub synonyms_total: usize,
312    pub grants_total: usize,
313    pub dependencies_total: usize,
314}
315
316/// Doctor-flagged missing privilege: the `plsql-catalog` driver could not
317/// observe an Oracle dictionary view that some upstream features require.
318#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
319pub struct MissingPermissionReport {
320    pub view_name: String,
321    pub required_for: Vec<String>,
322    pub suggested_grant: String,
323}
324
325/// Structured doctor report for a `CatalogSnapshot`.
326///
327/// Consumers (`plsql catalog doctor --robot-json`, `plsql-mcp` foundation
328/// tools, and the planned `plsql doctor` umbrella surface) can render the
329/// report directly or wrap it in a `RobotJsonEnvelope` for stable,
330/// versioned output.
331#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
332pub struct CatalogDoctorReport {
333    /// Identifier of the snapshot's origin (`live extraction via ...` or the
334    /// JSON snapshot path).
335    pub source_description: String,
336    pub source_kind: CatalogSourceKind,
337    pub generated_at: Option<DateTime<Utc>>,
338    pub totals: DoctorExtractionTotals,
339    pub object_counts: Vec<DoctorObjectCount>,
340    pub capability_warnings: Vec<CapabilityWarning>,
341    pub missing_permissions: Vec<MissingPermissionReport>,
342    /// Per-schema PL/Scope availability. Empty when the snapshot has no
343    /// PL/Scope detection wired.
344    pub plscope_availability_per_schema: Vec<PlScopeAvailabilityRow>,
345    /// Capability-bit copy for downstream consumers that don't want to read
346    /// the full `CatalogSnapshot` to learn whether a query family worked.
347    pub can_query_dba_views: bool,
348    pub can_query_all_views: bool,
349    pub can_use_dbms_metadata: bool,
350    pub can_read_source: bool,
351    pub plscope_enabled: bool,
352    pub can_query_scheduler: bool,
353    pub can_query_roles_and_grants: bool,
354}
355
356/// One row of the doctor report's per-schema PL/Scope availability summary.
357/// The `schema_name` is rendered through the snapshot's `SymbolInterner` so
358/// the report is stable across JSON snapshots and live extractions.
359#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
360pub struct PlScopeAvailabilityRow {
361    pub schema_name: String,
362    pub availability: PlScopeAvailability,
363}
364
365impl CatalogSnapshot {
366    /// Build the doctor report directly from this snapshot.
367    ///
368    /// The doctor is read-only — it never queries the DB itself; it
369    /// summarizes what was already extracted into the snapshot plus any
370    /// `CapabilityWarning`s the loader recorded. Missing-permission diagnoses
371    /// are inferred from per-family capability bits so the report is
372    /// equally useful for live-extracted and JSON-loaded snapshots.
373    #[must_use]
374    #[instrument(level = "trace", skip(self))]
375    pub fn doctor_report(&self) -> CatalogDoctorReport {
376        let mut counts: BTreeMap<ObjectType, DoctorObjectCount> = BTreeMap::new();
377        let mut columns_total = 0usize;
378        let mut indexes_total = 0usize;
379        let mut constraints_total = 0usize;
380        let mut triggers_total = 0usize;
381        let mut synonyms_total = 0usize;
382        let mut grants_total = 0usize;
383        let mut dependencies_total = 0usize;
384        let mut objects_total = 0usize;
385
386        for schema_catalog in self.schemas.values() {
387            for object in schema_catalog.objects.values() {
388                let common = catalog_object_common(object);
389                let tile = counts
390                    .entry(common.object_type)
391                    .or_insert(DoctorObjectCount {
392                        object_type: common.object_type,
393                        ..DoctorObjectCount::default()
394                    });
395                tile.total = tile.total.saturating_add(1);
396                match common.status {
397                    ObjectStatus::Valid => {
398                        tile.valid = tile.valid.saturating_add(1);
399                    }
400                    ObjectStatus::Invalid => {
401                        tile.invalid = tile.invalid.saturating_add(1);
402                    }
403                    ObjectStatus::NotApplicable => {
404                        tile.other = tile.other.saturating_add(1);
405                    }
406                }
407                objects_total = objects_total.saturating_add(1);
408
409                columns_total = columns_total.saturating_add(catalog_object_column_count(object));
410            }
411            indexes_total = indexes_total.saturating_add(schema_catalog.indexes.len());
412            constraints_total = constraints_total.saturating_add(schema_catalog.constraints.len());
413            triggers_total = triggers_total.saturating_add(schema_catalog.triggers.len());
414            synonyms_total = synonyms_total.saturating_add(schema_catalog.synonyms.len());
415            grants_total = grants_total.saturating_add(schema_catalog.grants.len());
416            dependencies_total =
417                dependencies_total.saturating_add(schema_catalog.dependencies.len());
418        }
419
420        let totals = DoctorExtractionTotals {
421            schemas_observed: self.schemas.len(),
422            objects_total,
423            columns_total,
424            indexes_total,
425            constraints_total,
426            triggers_total,
427            synonyms_total,
428            grants_total,
429            dependencies_total,
430        };
431
432        let mut object_counts: Vec<DoctorObjectCount> = counts.into_values().collect();
433        object_counts.sort_by_key(|tile| std::cmp::Reverse(tile.total));
434
435        let missing_permissions =
436            derive_missing_permission_reports(&self.capabilities, &self.source);
437
438        let mut plscope_availability_per_schema: Vec<PlScopeAvailabilityRow> = self
439            .schemas
440            .iter()
441            .filter_map(|(owner, schema_catalog)| {
442                let availability = schema_catalog.plscope.as_ref()?.availability;
443                let schema_name = self.interner.resolve(owner.symbol())?.to_string();
444                Some(PlScopeAvailabilityRow {
445                    schema_name,
446                    availability,
447                })
448            })
449            .collect();
450        plscope_availability_per_schema.sort_by(|a, b| a.schema_name.cmp(&b.schema_name));
451
452        CatalogDoctorReport {
453            source_description: self.source.description.clone().unwrap_or_default(),
454            source_kind: self.source.kind,
455            generated_at: Some(self.generated_at),
456            totals,
457            object_counts,
458            capability_warnings: self.capabilities.warnings.clone(),
459            missing_permissions,
460            plscope_availability_per_schema,
461            can_query_dba_views: self.capabilities.can_query_dba_views,
462            can_query_all_views: self.capabilities.can_query_all_views,
463            can_use_dbms_metadata: self.capabilities.can_use_dbms_metadata,
464            can_read_source: self.capabilities.can_read_source,
465            plscope_enabled: self.capabilities.plscope_enabled,
466            can_query_scheduler: self.capabilities.can_query_scheduler,
467            can_query_roles_and_grants: self.capabilities.can_query_roles_and_grants,
468        }
469    }
470}
471
472fn catalog_object_common(object: &CatalogObject) -> &ObjectCommon {
473    match object {
474        CatalogObject::Table(metadata) => &metadata.common,
475        CatalogObject::View(metadata) => &metadata.common,
476        CatalogObject::MaterializedView(metadata) => &metadata.common,
477        CatalogObject::Sequence(metadata) => &metadata.common,
478        CatalogObject::Type(metadata) => &metadata.common,
479        CatalogObject::Package(metadata) => &metadata.common,
480        CatalogObject::Procedure(metadata) => &metadata.common,
481        CatalogObject::Function(metadata) => &metadata.common,
482        CatalogObject::Trigger(metadata) => &metadata.common,
483        CatalogObject::SchedulerJob(metadata) => &metadata.common,
484        CatalogObject::EditioningView(metadata) => &metadata.common,
485    }
486}
487
488fn catalog_object_column_count(object: &CatalogObject) -> usize {
489    match object {
490        CatalogObject::Table(metadata) => metadata.columns.len(),
491        CatalogObject::View(metadata) => metadata.columns.len(),
492        CatalogObject::MaterializedView(metadata) => metadata.columns.len(),
493        CatalogObject::EditioningView(metadata) => metadata.columns.len(),
494        CatalogObject::Sequence(_)
495        | CatalogObject::Type(_)
496        | CatalogObject::Package(_)
497        | CatalogObject::Procedure(_)
498        | CatalogObject::Function(_)
499        | CatalogObject::Trigger(_)
500        | CatalogObject::SchedulerJob(_) => 0,
501    }
502}
503
504fn derive_missing_permission_reports(
505    capabilities: &CatalogCapabilities,
506    source: &CatalogSource,
507) -> Vec<MissingPermissionReport> {
508    // Missing-permission diagnoses only make sense for live extractions. A
509    // JSON snapshot was already produced once — its capability bits reflect
510    // the original extraction; we surface them verbatim instead of inventing
511    // new grant suggestions.
512    if !matches!(source.kind, CatalogSourceKind::LiveConnection) {
513        return Vec::new();
514    }
515
516    let mut reports = Vec::new();
517    if !capabilities.can_query_dba_views {
518        reports.push(MissingPermissionReport {
519            view_name: String::from("DBA_OBJECTS / DBA_TAB_COLUMNS / DBA_DEPENDENCIES"),
520            required_for: vec![
521                String::from("cross-schema extraction beyond ALL_*"),
522                String::from("PLSQL-CAT-014 dependency reachability over schemas"),
523            ],
524            suggested_grant: String::from(
525                "grant select_catalog_role to <user>; -- or individual grants on DBA_* views",
526            ),
527        });
528    }
529    if !capabilities.can_use_dbms_metadata {
530        reports.push(MissingPermissionReport {
531            view_name: String::from("DBMS_METADATA"),
532            required_for: vec![
533                String::from("PLSQL-CAT-015 DBMS_METADATA.GET_DDL extraction"),
534                String::from("normalized DDL hashes for `what-breaks`"),
535            ],
536            suggested_grant: String::from("grant execute on DBMS_METADATA to <user>;"),
537        });
538    }
539    if !capabilities.can_read_source {
540        reports.push(MissingPermissionReport {
541            view_name: String::from("ALL_SOURCE / DBA_SOURCE"),
542            required_for: vec![
543                String::from("packaged routine body inspection"),
544                String::from("get_object_source MCP tool"),
545            ],
546            suggested_grant: String::from(
547                "grant select on ALL_SOURCE to <user>; -- ALL_SOURCE itself is normally readable; ensure no DROP/REVOKE narrowed it",
548            ),
549        });
550    }
551    if !capabilities.plscope_enabled {
552        reports.push(MissingPermissionReport {
553            view_name: String::from("PLSCOPE_SETTINGS / ALL_IDENTIFIERS"),
554            required_for: vec![
555                String::from("PLSQL-CAT-010 PL/Scope availability detection"),
556                String::from("PLSQL-CAT-011 identifier extraction"),
557            ],
558            suggested_grant: String::from(
559                "alter session set plscope_settings = 'identifiers:all'; -- and recompile target objects",
560            ),
561        });
562    }
563    if !capabilities.can_query_scheduler {
564        reports.push(MissingPermissionReport {
565            view_name: String::from("ALL_SCHEDULER_JOBS / ALL_SCHEDULER_PROGRAMS"),
566            required_for: vec![String::from("scheduler job lineage edges")],
567            suggested_grant: String::from(
568                "grant select on ALL_SCHEDULER_JOBS to <user>; grant select on ALL_SCHEDULER_PROGRAMS to <user>;",
569            ),
570        });
571    }
572    if !capabilities.can_query_roles_and_grants {
573        reports.push(MissingPermissionReport {
574            view_name: String::from("DBA_ROLE_PRIVS / DBA_SYS_PRIVS / DBA_TAB_PRIVS"),
575            required_for: vec![
576                String::from("definer-rights privilege chain analysis"),
577                String::from("role-mediated execution evidence (PRIVILEGES-* beads)"),
578            ],
579            suggested_grant: String::from(
580                "grant select_catalog_role to <user>; -- enables DBA_*_PRIVS reads",
581            ),
582        });
583    }
584    reports
585}
586
587#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
588pub struct CatalogSnapshotDocument {
589    pub schema_id: String,
590    pub schema_version: SchemaVersion,
591    pub snapshot: CatalogSnapshot,
592}
593
594impl CatalogSnapshotDocument {
595    #[must_use]
596    #[instrument(level = "trace", skip(snapshot))]
597    pub fn new(snapshot: CatalogSnapshot) -> Self {
598        Self {
599            schema_id: String::from(CATALOG_SNAPSHOT_SCHEMA_ID),
600            schema_version: CATALOG_SNAPSHOT_SCHEMA_VERSION,
601            snapshot,
602        }
603    }
604}
605
606#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
607pub struct CatalogLoadRequest {
608    pub schema_filters: Vec<CatalogSchemaFilter>,
609}
610
611impl CatalogLoadRequest {
612    #[must_use]
613    #[instrument(level = "trace")]
614    pub fn for_current_schema() -> Self {
615        Self {
616            schema_filters: vec![CatalogSchemaFilter::CurrentSchema],
617        }
618    }
619
620    #[must_use]
621    #[instrument(level = "trace", skip(schema_names))]
622    pub fn for_named_schemas<I, S>(schema_names: I) -> Self
623    where
624        I: IntoIterator<Item = S>,
625        S: Into<String>,
626    {
627        Self {
628            schema_filters: schema_names
629                .into_iter()
630                .map(CatalogSchemaFilter::named)
631                .collect(),
632        }
633    }
634}
635
636impl Default for CatalogLoadRequest {
637    fn default() -> Self {
638        Self::for_current_schema()
639    }
640}
641
642#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
643pub enum CatalogSchemaFilter {
644    CurrentSchema,
645    Named(String),
646}
647
648impl CatalogSchemaFilter {
649    #[must_use]
650    #[instrument(level = "trace")]
651    pub fn current_schema() -> Self {
652        Self::CurrentSchema
653    }
654
655    #[must_use]
656    #[instrument(level = "trace", skip(schema_name))]
657    pub fn named(schema_name: impl Into<String>) -> Self {
658        Self::Named(schema_name.into())
659    }
660}
661
662#[derive(Debug, Error)]
663pub enum CatalogError {
664    #[error("i/o error: {0}")]
665    Io(#[from] std::io::Error),
666    #[error("json error: {0}")]
667    Json(#[from] serde_json::Error),
668    #[error("oracle backend `{backend}` is unavailable in this build; use `{feature}`")]
669    OracleBackendNotCompiled {
670        backend: OracleBackend,
671        feature: &'static str,
672    },
673    #[error("oracle backend `{backend}` error: {message}")]
674    OracleBackendError {
675        backend: OracleBackend,
676        message: String,
677    },
678    #[error("expected {expected} row(s) but received {actual}")]
679    UnexpectedRowCount { expected: String, actual: usize },
680    #[error("required column `{column}` was missing from the query result")]
681    MissingColumn { column: String },
682    #[error("column `{column}` was null")]
683    NullColumnValue { column: String },
684    #[error("column `{column}` could not be parsed as {expected}: `{value}`")]
685    InvalidColumnValue {
686        column: String,
687        expected: &'static str,
688        value: String,
689    },
690    #[error("unsupported catalog snapshot schema {found} for {schema_id}; expected {expected}")]
691    UnsupportedSchemaVersion {
692        schema_id: String,
693        found: SchemaVersion,
694        expected: SchemaVersion,
695    },
696    #[error("unexpected catalog snapshot schema id `{0}`")]
697    UnexpectedSchemaId(String),
698    #[error("catalog load request could not resolve the current schema from the Oracle connection")]
699    CurrentSchemaUnavailable,
700    #[error("schema filter `{schema_name}` is invalid: schema names must not be blank")]
701    InvalidSchemaFilter { schema_name: String },
702}
703
704#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
705pub enum OracleBackend {
706    RustOracle,
707    OracleRs,
708}
709
710impl OracleBackend {
711    #[must_use]
712    #[instrument(level = "trace", skip(self))]
713    pub fn as_str(self) -> &'static str {
714        match self {
715            Self::RustOracle => "oracle",
716            Self::OracleRs => "oracle-rs",
717        }
718    }
719}
720
721impl std::fmt::Display for OracleBackend {
722    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
723        f.write_str(self.as_str())
724    }
725}
726
727#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
728pub struct OracleConnectOptions {
729    pub username: String,
730    pub password: String,
731    pub connect_string: String,
732    pub current_schema: Option<String>,
733    pub module: Option<String>,
734    pub action: Option<String>,
735    pub client_info: Option<String>,
736    pub client_identifier: Option<String>,
737}
738
739impl OracleConnectOptions {
740    #[must_use]
741    pub fn new(
742        username: impl Into<String>,
743        password: impl Into<String>,
744        connect_string: impl Into<String>,
745    ) -> Self {
746        Self {
747            username: username.into(),
748            password: password.into(),
749            connect_string: connect_string.into(),
750            current_schema: None,
751            module: None,
752            action: None,
753            client_info: None,
754            client_identifier: None,
755        }
756    }
757
758    #[must_use]
759    pub fn with_current_schema(mut self, current_schema: impl Into<String>) -> Self {
760        self.current_schema = Some(current_schema.into());
761        self
762    }
763
764    #[must_use]
765    pub fn with_module(mut self, module: impl Into<String>) -> Self {
766        self.module = Some(module.into());
767        self
768    }
769
770    #[must_use]
771    pub fn with_action(mut self, action: impl Into<String>) -> Self {
772        self.action = Some(action.into());
773        self
774    }
775
776    #[must_use]
777    pub fn with_client_info(mut self, client_info: impl Into<String>) -> Self {
778        self.client_info = Some(client_info.into());
779        self
780    }
781
782    #[must_use]
783    pub fn with_client_identifier(mut self, client_identifier: impl Into<String>) -> Self {
784        self.client_identifier = Some(client_identifier.into());
785        self
786    }
787}
788
789#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
790pub enum OracleBind {
791    String(String),
792    I64(i64),
793    U64(u64),
794    Bool(bool),
795}
796
797impl From<&str> for OracleBind {
798    fn from(value: &str) -> Self {
799        Self::String(String::from(value))
800    }
801}
802
803impl From<String> for OracleBind {
804    fn from(value: String) -> Self {
805        Self::String(value)
806    }
807}
808
809impl From<i32> for OracleBind {
810    fn from(value: i32) -> Self {
811        Self::I64(i64::from(value))
812    }
813}
814
815impl From<i64> for OracleBind {
816    fn from(value: i64) -> Self {
817        Self::I64(value)
818    }
819}
820
821impl From<u32> for OracleBind {
822    fn from(value: u32) -> Self {
823        Self::U64(u64::from(value))
824    }
825}
826
827impl From<u64> for OracleBind {
828    fn from(value: u64) -> Self {
829        Self::U64(value)
830    }
831}
832
833impl From<bool> for OracleBind {
834    fn from(value: bool) -> Self {
835        Self::Bool(value)
836    }
837}
838
839#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
840pub struct OracleCell {
841    pub oracle_type: String,
842    pub value: Option<String>,
843}
844
845impl OracleCell {
846    #[must_use]
847    #[instrument(level = "trace", skip(oracle_type, value))]
848    pub fn new(oracle_type: impl Into<String>, value: Option<String>) -> Self {
849        Self {
850            oracle_type: oracle_type.into(),
851            value,
852        }
853    }
854}
855
856#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
857pub struct OracleRow {
858    pub columns: BTreeMap<String, OracleCell>,
859}
860
861impl OracleRow {
862    pub fn insert(
863        &mut self,
864        name: impl Into<String>,
865        oracle_type: impl Into<String>,
866        value: Option<String>,
867    ) {
868        self.columns.insert(
869            name.into().to_ascii_uppercase(),
870            OracleCell::new(oracle_type, value),
871        );
872    }
873
874    #[must_use]
875    #[instrument(level = "trace", skip(self))]
876    pub fn cell(&self, name: &str) -> Option<&OracleCell> {
877        self.columns.get(&name.to_ascii_uppercase())
878    }
879
880    #[must_use]
881    #[instrument(level = "trace", skip(self))]
882    pub fn text(&self, name: &str) -> Option<&str> {
883        self.cell(name).and_then(|cell| cell.value.as_deref())
884    }
885
886    #[instrument(level = "trace", skip(self))]
887    pub fn require_text(&self, name: &str) -> Result<&str, CatalogError> {
888        let Some(cell) = self.cell(name) else {
889            return Err(CatalogError::MissingColumn {
890                column: name.to_ascii_uppercase(),
891            });
892        };
893        cell.value
894            .as_deref()
895            .ok_or_else(|| CatalogError::NullColumnValue {
896                column: name.to_ascii_uppercase(),
897            })
898    }
899
900    #[instrument(level = "trace", skip(self))]
901    pub fn parse_i64(&self, name: &str) -> Result<i64, CatalogError> {
902        let text = self.require_text(name)?;
903        text.parse::<i64>()
904            .map_err(|_| CatalogError::InvalidColumnValue {
905                column: name.to_ascii_uppercase(),
906                expected: "i64",
907                value: String::from(text),
908            })
909    }
910
911    #[instrument(level = "trace", skip(self))]
912    pub fn parse_u64(&self, name: &str) -> Result<u64, CatalogError> {
913        let text = self.require_text(name)?;
914        text.parse::<u64>()
915            .map_err(|_| CatalogError::InvalidColumnValue {
916                column: name.to_ascii_uppercase(),
917                expected: "u64",
918                value: String::from(text),
919            })
920    }
921
922    #[instrument(level = "trace", skip(self))]
923    pub fn parse_bool(&self, name: &str) -> Result<bool, CatalogError> {
924        let text = self.require_text(name)?;
925        let normalized = text.trim().to_ascii_uppercase();
926        match normalized.as_str() {
927            "Y" | "YES" | "TRUE" | "1" => Ok(true),
928            "N" | "NO" | "FALSE" | "0" => Ok(false),
929            _ => Err(CatalogError::InvalidColumnValue {
930                column: name.to_ascii_uppercase(),
931                expected: "bool",
932                value: String::from(text),
933            }),
934        }
935    }
936}
937
938#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
939pub struct OracleConnectionInfo {
940    pub backend: OracleBackend,
941    pub connect_string: String,
942    pub current_schema: Option<String>,
943    pub server_version: String,
944    pub db_name: String,
945    pub db_domain: String,
946    pub service_name: String,
947    pub instance_name: String,
948    pub server_type: String,
949    pub max_identifier_length: u32,
950    pub max_open_cursors: u32,
951}
952
953#[async_trait::async_trait(?Send)]
954pub trait OracleConnection: Send + Sync {
955    fn backend(&self) -> OracleBackend;
956    async fn ping(&self, cx: &Cx) -> Result<(), CatalogError>;
957    async fn describe(&self, cx: &Cx) -> Result<OracleConnectionInfo, CatalogError>;
958    async fn query_rows(
959        &self,
960        cx: &Cx,
961        sql: &str,
962        params: &[OracleBind],
963    ) -> Result<Vec<OracleRow>, CatalogError>;
964    async fn execute(&self, cx: &Cx, sql: &str, params: &[OracleBind])
965    -> Result<u64, CatalogError>;
966
967    #[instrument(level = "trace", skip(self, sql, params))]
968    async fn query_optional_row(
969        &self,
970        cx: &Cx,
971        sql: &str,
972        params: &[OracleBind],
973    ) -> Result<Option<OracleRow>, CatalogError> {
974        let mut rows = self.query_rows(cx, sql, params).await?;
975        match rows.len() {
976            0 => Ok(None),
977            1 => Ok(rows.pop()),
978            actual => Err(CatalogError::UnexpectedRowCount {
979                expected: String::from("0 or 1"),
980                actual,
981            }),
982        }
983    }
984
985    #[instrument(level = "trace", skip(self, sql, params))]
986    async fn query_one_row(
987        &self,
988        cx: &Cx,
989        sql: &str,
990        params: &[OracleBind],
991    ) -> Result<OracleRow, CatalogError> {
992        let mut rows = self.query_rows(cx, sql, params).await?;
993        match rows.len() {
994            1 => rows.pop().ok_or(CatalogError::UnexpectedRowCount {
995                expected: String::from("exactly 1"),
996                actual: 0,
997            }),
998            actual => Err(CatalogError::UnexpectedRowCount {
999                expected: String::from("exactly 1"),
1000                actual,
1001            }),
1002        }
1003    }
1004}
1005
1006/// Pure-Rust thin Oracle adapter over the shared `oraclemcp-db` connection
1007/// layer.
1008///
1009/// This adapter is optional and exists for live-XE tests and lower-layer
1010/// callers that need a concrete implementation of [`OracleConnection`] without
1011/// depending on the MCP crate. The default catalog crate remains offline-first.
1012#[cfg(feature = "oraclemcp-db")]
1013pub struct OraclemcpDbConnection {
1014    inner: oraclemcp_db::RustOracleConnection,
1015    connect_string: String,
1016}
1017
1018#[cfg(feature = "oraclemcp-db")]
1019impl OraclemcpDbConnection {
1020    /// Open a pure-Rust thin Oracle connection.
1021    pub async fn connect(
1022        cx: &Cx,
1023        options: oraclemcp_db::OracleConnectOptions,
1024    ) -> Result<Self, CatalogError> {
1025        let connect_string = options.connect_string.clone();
1026        let inner = oraclemcp_db::RustOracleConnection::connect(cx, options)
1027            .await
1028            .map_err(map_oraclemcp_db_error)?;
1029        Ok(Self {
1030            inner,
1031            connect_string,
1032        })
1033    }
1034
1035    /// Open a password-authenticated pure-Rust thin Oracle connection with a
1036    /// module/action identity.
1037    pub async fn connect_with_password(
1038        cx: &Cx,
1039        username: impl Into<String>,
1040        password: impl Into<String>,
1041        connect_string: impl Into<String>,
1042        module: impl Into<String>,
1043        action: impl Into<String>,
1044    ) -> Result<Self, CatalogError> {
1045        let options = oraclemcp_db::OracleConnectOptions {
1046            connect_string: connect_string.into(),
1047            username: Some(username.into()),
1048            password: Some(password.into()),
1049            session_identity: Some(oraclemcp_db::OracleSessionIdentity {
1050                module: Some(module.into()),
1051                action: Some(action.into()),
1052                ..oraclemcp_db::OracleSessionIdentity::default()
1053            }),
1054            ..oraclemcp_db::OracleConnectOptions::default()
1055        };
1056        Self::connect(cx, options).await
1057    }
1058
1059    /// Borrow the underlying shared driver connection.
1060    #[must_use]
1061    pub fn inner(&self) -> &oraclemcp_db::RustOracleConnection {
1062        &self.inner
1063    }
1064}
1065
1066#[cfg(feature = "oraclemcp-db")]
1067#[async_trait::async_trait(?Send)]
1068impl OracleConnection for OraclemcpDbConnection {
1069    #[instrument(level = "trace", skip(self))]
1070    fn backend(&self) -> OracleBackend {
1071        OracleBackend::OracleRs
1072    }
1073
1074    #[instrument(level = "trace", skip(self))]
1075    async fn ping(&self, cx: &Cx) -> Result<(), CatalogError> {
1076        oraclemcp_db::OracleConnection::ping(&self.inner, cx)
1077            .await
1078            .map_err(map_oraclemcp_db_error)
1079    }
1080
1081    #[instrument(level = "trace", skip(self))]
1082    async fn describe(&self, cx: &Cx) -> Result<OracleConnectionInfo, CatalogError> {
1083        oraclemcp_db::OracleConnection::describe(&self.inner, cx)
1084            .await
1085            .map(|info| map_oraclemcp_connection_info(info, &self.connect_string))
1086            .map_err(map_oraclemcp_db_error)
1087    }
1088
1089    #[instrument(level = "trace", skip(self, sql, params))]
1090    async fn query_rows(
1091        &self,
1092        cx: &Cx,
1093        sql: &str,
1094        params: &[OracleBind],
1095    ) -> Result<Vec<OracleRow>, CatalogError> {
1096        let binds = map_oraclemcp_binds(params)?;
1097        oraclemcp_db::OracleConnection::query_rows(&self.inner, cx, sql, &binds)
1098            .await
1099            .map(map_oraclemcp_rows)
1100            .map_err(map_oraclemcp_db_error)
1101    }
1102
1103    #[instrument(level = "trace", skip(self, sql, params))]
1104    async fn execute(
1105        &self,
1106        cx: &Cx,
1107        sql: &str,
1108        params: &[OracleBind],
1109    ) -> Result<u64, CatalogError> {
1110        let binds = map_oraclemcp_binds(params)?;
1111        oraclemcp_db::OracleConnection::execute(&self.inner, cx, sql, &binds)
1112            .await
1113            .map_err(map_oraclemcp_db_error)
1114    }
1115}
1116
1117#[cfg(feature = "oraclemcp-db")]
1118fn map_oraclemcp_connection_info(
1119    info: oraclemcp_db::OracleConnectionInfo,
1120    connect_string: &str,
1121) -> OracleConnectionInfo {
1122    OracleConnectionInfo {
1123        backend: OracleBackend::OracleRs,
1124        connect_string: connect_string.to_owned(),
1125        current_schema: info.current_schema,
1126        server_version: info.server_version.unwrap_or_default(),
1127        db_name: String::new(),
1128        db_domain: String::new(),
1129        service_name: String::new(),
1130        instance_name: String::new(),
1131        server_type: info.database_role.unwrap_or_default(),
1132        max_identifier_length: 128,
1133        max_open_cursors: 0,
1134    }
1135}
1136
1137#[cfg(feature = "oraclemcp-db")]
1138fn map_oraclemcp_binds(
1139    params: &[OracleBind],
1140) -> Result<Vec<oraclemcp_db::OracleBind>, CatalogError> {
1141    params
1142        .iter()
1143        .map(|param| match param {
1144            OracleBind::String(value) => Ok(oraclemcp_db::OracleBind::String(value.clone())),
1145            OracleBind::I64(value) => Ok(oraclemcp_db::OracleBind::I64(*value)),
1146            OracleBind::U64(value) => {
1147                let signed =
1148                    i64::try_from(*value).map_err(|_| CatalogError::InvalidColumnValue {
1149                        column: String::from("bind"),
1150                        expected: "u64 <= i64::MAX for oraclemcp-db positional bind",
1151                        value: value.to_string(),
1152                    })?;
1153                Ok(oraclemcp_db::OracleBind::I64(signed))
1154            }
1155            OracleBind::Bool(value) => Ok(oraclemcp_db::OracleBind::Bool(*value)),
1156        })
1157        .collect()
1158}
1159
1160#[cfg(feature = "oraclemcp-db")]
1161fn map_oraclemcp_rows(rows: Vec<oraclemcp_db::OracleRow>) -> Vec<OracleRow> {
1162    rows.into_iter().map(map_oraclemcp_row).collect()
1163}
1164
1165#[cfg(feature = "oraclemcp-db")]
1166fn map_oraclemcp_row(row: oraclemcp_db::OracleRow) -> OracleRow {
1167    let mut mapped = OracleRow::default();
1168    for (name, cell) in row.columns {
1169        mapped.columns.insert(
1170            name.to_ascii_uppercase(),
1171            OracleCell::new(cell.oracle_type, cell.value),
1172        );
1173    }
1174    mapped
1175}
1176
1177#[cfg(feature = "oraclemcp-db")]
1178fn map_oraclemcp_db_error(err: oraclemcp_db::DbError) -> CatalogError {
1179    CatalogError::OracleBackendError {
1180        backend: OracleBackend::OracleRs,
1181        message: err.to_string(),
1182    }
1183}
1184
1185#[instrument(level = "trace")]
1186pub fn load_snapshot_from_json(path: &std::path::Path) -> Result<CatalogSnapshot, CatalogError> {
1187    let raw = fs::read_to_string(path)?;
1188    let document: CatalogSnapshotDocument = serde_json::from_str(&raw)?;
1189
1190    if !document.schema_id.as_str().eq(CATALOG_SNAPSHOT_SCHEMA_ID) {
1191        return Err(CatalogError::UnexpectedSchemaId(document.schema_id));
1192    }
1193
1194    if !matches!(
1195        document
1196            .schema_version
1197            .cmp(&CATALOG_SNAPSHOT_SCHEMA_VERSION),
1198        std::cmp::Ordering::Equal
1199    ) {
1200        return Err(CatalogError::UnsupportedSchemaVersion {
1201            schema_id: String::from(CATALOG_SNAPSHOT_SCHEMA_ID),
1202            found: document.schema_version,
1203            expected: CATALOG_SNAPSHOT_SCHEMA_VERSION,
1204        });
1205    }
1206
1207    Ok(document.snapshot)
1208}
1209
1210#[instrument(level = "trace", skip(snapshot))]
1211pub fn export_snapshot_to_json(
1212    snapshot: &CatalogSnapshot,
1213    path: &std::path::Path,
1214) -> Result<(), CatalogError> {
1215    let document = CatalogSnapshotDocument::new(snapshot.clone());
1216    let rendered = serde_json::to_string_pretty(&document)?;
1217    fs::write(path, rendered)?;
1218    Ok(())
1219}
1220
1221/// Load a catalog snapshot from a directory of DBMS_METADATA-exported .sql files.
1222///
1223/// Load a `CatalogSnapshot` by classifying every `.sql` file under `dir` as a
1224/// single top-level CREATE DDL statement (the shape `DBMS_METADATA.GET_DDL`
1225/// emits when written per-object to disk).
1226///
1227/// For each file:
1228///
1229/// * The object kind is read from the leading `CREATE …` keyword
1230///   (`TABLE` / `VIEW` / `PACKAGE` / `PROCEDURE` / `FUNCTION` /
1231///   `SEQUENCE` / `TRIGGER` / `TYPE`); statements whose keyword does
1232///   not match a known kind are skipped (graceful degradation per
1233///   R13).
1234/// * The owner schema is read from the optional `OWNER.OBJECT` prefix
1235///   on the CREATE target. Unqualified statements (no `OWNER.`
1236///   prefix) are filed under a stable `PUBLIC` schema interned through
1237///   the regular interner — never `SymbolId::new(0)`, which would
1238///   collide with whatever the first object name happens to be.
1239/// * The raw file bytes are stored verbatim on
1240///   [`ObjectCommon::ddl`] as a [`DbmsMetadataDdl`] so downstream
1241///   consumers (doc generation, lineage, the doctor's
1242///   ddl-extraction ratio) can inspect the exact source the catalog
1243///   was derived from.
1244///
1245/// This classifier is keyword-shaped and does not parse arbitrary
1246/// PL/SQL bodies — column definitions, parameter signatures, view
1247/// projections and constraint details are *not* populated. When the
1248/// full parser (Layer 1) lands, callers that need column- or
1249/// signature-level fidelity should switch to that path; the
1250/// `DbmsMetadataDdl` stored here is sufficient seed for re-parsing
1251/// on demand without re-reading the disk.
1252#[instrument(level = "info", skip_all, fields(dir = %dir.display()))]
1253pub fn load_from_dbms_metadata_dir(dir: &std::path::Path) -> Result<CatalogSnapshot, CatalogError> {
1254    if !dir.is_dir() {
1255        return Err(CatalogError::Io(std::io::Error::new(
1256            std::io::ErrorKind::NotFound,
1257            format!("not a directory: {}", dir.display()),
1258        )));
1259    }
1260
1261    let mut interner = SymbolInterner::default();
1262    let mut schemas: HashMap<SchemaName, SchemaCatalog> = HashMap::new();
1263    let mut file_count = 0usize;
1264    let mut classified_count = 0usize;
1265
1266    // Collect + sort entries so the resulting snapshot (and its
1267    // interner symbol ids) are deterministic across runs and
1268    // platforms — `read_dir` ordering is unspecified.
1269    let mut paths: Vec<std::path::PathBuf> = fs::read_dir(dir)?
1270        .filter_map(|e| e.ok().map(|e| e.path()))
1271        .filter(|p| {
1272            p.extension()
1273                .and_then(|e| e.to_str())
1274                .is_some_and(|ext| ext.eq("sql"))
1275        })
1276        .collect();
1277    paths.sort();
1278
1279    for path in paths {
1280        file_count += 1;
1281        let ddl_text = match fs::read_to_string(&path) {
1282            Ok(text) => text,
1283            Err(_) => continue,
1284        };
1285
1286        if let Some((schema, obj_name, obj)) = classify_dbms_metadata_ddl(&ddl_text, &mut interner)
1287        {
1288            let schema_catalog = schemas.entry(schema).or_default();
1289            schema_catalog.objects.insert(obj_name, obj);
1290            classified_count += 1;
1291        }
1292    }
1293
1294    tracing::info!(
1295        files = file_count,
1296        classified = classified_count,
1297        "loaded DBMS_METADATA directory"
1298    );
1299
1300    Ok(CatalogSnapshot {
1301        schemas,
1302        profile: AnalysisProfile::default(),
1303        capabilities: CatalogCapabilities {
1304            can_query_all_views: false,
1305            can_query_dba_views: false,
1306            can_use_dbms_metadata: true,
1307            can_read_source: true,
1308            plscope_enabled: false,
1309            can_query_scheduler: false,
1310            can_query_roles_and_grants: false,
1311            warnings: vec![],
1312        },
1313        generated_at: Utc::now(),
1314        source: CatalogSource {
1315            kind: CatalogSourceKind::DbmsMetadataFiles,
1316            description: Some(format!("loaded from {}", dir.display())),
1317            ..CatalogSource::default()
1318        },
1319        interner,
1320        editions: Vec::new(),
1321        // DBMS_METADATA directory loads do not query ALL_USERS; grantee
1322        // classification is not exercised on this path.
1323        known_users: None,
1324    })
1325}
1326
1327/// Default schema name used when a CREATE statement has no `OWNER.`
1328/// prefix. Interned through the regular interner so the resulting
1329/// `SchemaName` has a real, resolvable text — never a collision with
1330/// `SymbolId::new(0)`.
1331const UNQUALIFIED_DDL_SCHEMA: &str = "PUBLIC";
1332
1333/// Classify a single per-file DDL statement into a `CatalogObject`.
1334///
1335/// Returns `None` for whitespace-only / comment-only files and for
1336/// CREATE statements whose object kind keyword is not in the
1337/// known set. The DDL bytes are preserved verbatim on
1338/// [`ObjectCommon::ddl`] so downstream code can re-parse them as
1339/// fidelity improves.
1340fn classify_dbms_metadata_ddl(
1341    ddl_text: &str,
1342    interner: &mut SymbolInterner,
1343) -> Option<(SchemaName, ObjectName, CatalogObject)> {
1344    // Parse the DDL HEADER as a real token stream — never substring-match
1345    // the whole DDL. Body / comment text that mentions `TABLE` etc. used
1346    // to silently re-classify VIEWs and PROCEDUREs as tables.
1347    let header = parse_create_header(ddl_text)?;
1348
1349    // `PACKAGE BODY` / `TYPE BODY` are bodies — the spec's catalog row
1350    // is the source of truth. Honest uncertainty: return None.
1351    if matches!(
1352        header.kind,
1353        DdlKind::PackageBody | DdlKind::TypeBody | DdlKind::Unknown
1354    ) {
1355        return None;
1356    }
1357
1358    let (owner_text, object_text) = extract_owner_and_name(&header.after_kind)?;
1359
1360    let owner_text = owner_text.unwrap_or_else(|| UNQUALIFIED_DDL_SCHEMA.to_string());
1361    let owner = interner.intern_schema_name(owner_text)?;
1362    let name_sid = interner.intern(&object_text)?;
1363    let obj_name = ObjectName::new(name_sid);
1364
1365    let ddl = DbmsMetadataDdl {
1366        ddl_text: ddl_text.to_string(),
1367        normalized_ddl: Some(normalize_dbms_metadata_ddl(ddl_text)),
1368        xml_text: None,
1369    };
1370
1371    let common = ObjectCommon {
1372        owner,
1373        name: obj_name,
1374        object_type: header.kind.object_type(),
1375        ddl: Some(ddl),
1376        ..ObjectCommon::default()
1377    };
1378
1379    let object = match header.kind {
1380        DdlKind::Table => CatalogObject::Table(TableMetadata {
1381            common,
1382            ..TableMetadata::default()
1383        }),
1384        DdlKind::View => CatalogObject::View(ViewMetadata {
1385            common,
1386            ..ViewMetadata::default()
1387        }),
1388        DdlKind::MaterializedView => CatalogObject::MaterializedView(MViewMetadata {
1389            common,
1390            ..MViewMetadata::default()
1391        }),
1392        DdlKind::Package => CatalogObject::Package(PackageMetadata {
1393            common,
1394            ..PackageMetadata::default()
1395        }),
1396        DdlKind::Procedure => CatalogObject::Procedure(ProcedureMetadata {
1397            common,
1398            signature: RoutineSignature {
1399                routine_name: obj_name,
1400                ..RoutineSignature::default()
1401            },
1402        }),
1403        DdlKind::Function => CatalogObject::Function(FunctionMetadata {
1404            common,
1405            signature: RoutineSignature {
1406                routine_name: obj_name,
1407                ..RoutineSignature::default()
1408            },
1409            ..FunctionMetadata::default()
1410        }),
1411        DdlKind::Sequence => CatalogObject::Sequence(SequenceMetadata {
1412            common,
1413            ..SequenceMetadata::default()
1414        }),
1415        DdlKind::Trigger => CatalogObject::Trigger(TriggerMetadata {
1416            common,
1417            ..TriggerMetadata::default()
1418        }),
1419        DdlKind::Type => CatalogObject::Type(TypeMetadata {
1420            common,
1421            ..TypeMetadata::default()
1422        }),
1423        // Filtered above — the match is exhaustive only because we
1424        // handle every concrete kind.
1425        DdlKind::PackageBody | DdlKind::TypeBody | DdlKind::Unknown => return None,
1426    };
1427
1428    Some((owner, obj_name, object))
1429}
1430
1431/// Object kinds the per-file DDL classifier recognizes. `Unknown`
1432/// represents honest uncertainty (R13) — the header didn't tokenize
1433/// into a kind we model. `PackageBody` / `TypeBody` are recognized
1434/// separately so the classifier can skip them without confusing them
1435/// with their specs.
1436#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1437enum DdlKind {
1438    Table,
1439    View,
1440    MaterializedView,
1441    Package,
1442    PackageBody,
1443    Procedure,
1444    Function,
1445    Sequence,
1446    Trigger,
1447    Type,
1448    TypeBody,
1449    Unknown,
1450}
1451
1452impl DdlKind {
1453    fn object_type(self) -> ObjectType {
1454        match self {
1455            DdlKind::Table => ObjectType::Table,
1456            DdlKind::View => ObjectType::View,
1457            DdlKind::MaterializedView => ObjectType::MaterializedView,
1458            DdlKind::Package | DdlKind::PackageBody => ObjectType::Package,
1459            DdlKind::Procedure => ObjectType::Procedure,
1460            DdlKind::Function => ObjectType::Function,
1461            DdlKind::Sequence => ObjectType::Sequence,
1462            DdlKind::Trigger => ObjectType::Trigger,
1463            DdlKind::Type | DdlKind::TypeBody => ObjectType::Type,
1464            DdlKind::Unknown => ObjectType::Unknown,
1465        }
1466    }
1467}
1468
1469/// Parsed CREATE header: the typed `DdlKind` plus the upper-cased
1470/// remainder of the DDL starting immediately after the kind tokens.
1471/// Callers use `after_kind` to locate the `[OWNER.]NAME` — it has
1472/// already been stripped of leading comments / whitespace / `CREATE`
1473/// modifiers / kind tokens so a substring match in there cannot be
1474/// fooled by body content.
1475#[derive(Clone, Debug)]
1476struct ParsedCreateHeader {
1477    kind: DdlKind,
1478    after_kind: String,
1479}
1480
1481/// Parse the CREATE header of a raw DDL string.
1482///
1483/// Skips leading whitespace, `--` line comments, and `/* … */` block
1484/// comments. Consumes `CREATE` then optional `OR REPLACE`, optional
1485/// `FORCE` / `EDITIONABLE` / `NONEDITIONABLE` (in any order), then
1486/// reads one or two tokens to form a [`DdlKind`] (multi-word kinds
1487/// `MATERIALIZED VIEW`, `PACKAGE BODY`, `TYPE BODY` handled). Returns
1488/// `None` only when the input has no `CREATE` token at all; an
1489/// unrecognized kind word produces `DdlKind::Unknown` so callers can
1490/// represent honest uncertainty (R13).
1491fn parse_create_header(ddl: &str) -> Option<ParsedCreateHeader> {
1492    let mut cursor = Cursor::new(ddl);
1493    cursor.skip_ws_and_comments();
1494
1495    // Must start with `CREATE`.
1496    if !cursor.consume_keyword("CREATE") {
1497        return None;
1498    }
1499    cursor.skip_ws_and_comments();
1500
1501    // Optional `OR REPLACE`.
1502    if cursor.consume_keyword("OR") {
1503        cursor.skip_ws_and_comments();
1504        // `OR` without `REPLACE` is malformed; let it fall through to
1505        // kind parsing — the kind word won't match and we'll honestly
1506        // return `Unknown`.
1507        let _ = cursor.consume_keyword("REPLACE");
1508        cursor.skip_ws_and_comments();
1509    }
1510
1511    // Optional `FORCE` / `EDITIONABLE` / `NONEDITIONABLE` modifiers,
1512    // any order, any subset.
1513    loop {
1514        if cursor.consume_keyword("FORCE")
1515            || cursor.consume_keyword("NONEDITIONABLE")
1516            || cursor.consume_keyword("EDITIONABLE")
1517            || cursor.consume_keyword("NO")
1518        {
1519            cursor.skip_ws_and_comments();
1520            continue;
1521        }
1522        break;
1523    }
1524
1525    // Read the kind word (one token, possibly extended to two for
1526    // `MATERIALIZED VIEW` / `PACKAGE BODY` / `TYPE BODY`).
1527    let first = match cursor.consume_identifier() {
1528        Some(tok) => tok,
1529        None => {
1530            return Some(ParsedCreateHeader {
1531                kind: DdlKind::Unknown,
1532                after_kind: cursor.upper_remainder(),
1533            });
1534        }
1535    };
1536    cursor.skip_ws_and_comments();
1537
1538    // Speculatively look at the second token without committing — only
1539    // commit if it forms a known two-word kind.
1540    let kind = match first.as_str() {
1541        "MATERIALIZED" => {
1542            if cursor.peek_keyword("VIEW") {
1543                cursor.consume_keyword("VIEW");
1544                cursor.skip_ws_and_comments();
1545                DdlKind::MaterializedView
1546            } else {
1547                DdlKind::Unknown
1548            }
1549        }
1550        "PACKAGE" => {
1551            if cursor.peek_keyword("BODY") {
1552                cursor.consume_keyword("BODY");
1553                cursor.skip_ws_and_comments();
1554                DdlKind::PackageBody
1555            } else {
1556                DdlKind::Package
1557            }
1558        }
1559        "TYPE" => {
1560            if cursor.peek_keyword("BODY") {
1561                cursor.consume_keyword("BODY");
1562                cursor.skip_ws_and_comments();
1563                DdlKind::TypeBody
1564            } else {
1565                DdlKind::Type
1566            }
1567        }
1568        "TABLE" => DdlKind::Table,
1569        "VIEW" => DdlKind::View,
1570        "PROCEDURE" => DdlKind::Procedure,
1571        "FUNCTION" => DdlKind::Function,
1572        "SEQUENCE" => DdlKind::Sequence,
1573        "TRIGGER" => DdlKind::Trigger,
1574        _ => DdlKind::Unknown,
1575    };
1576
1577    Some(ParsedCreateHeader {
1578        kind,
1579        after_kind: cursor.upper_remainder(),
1580    })
1581}
1582
1583/// Hand-rolled byte cursor for the CREATE header tokenizer.
1584///
1585/// Only knows enough about SQL to skip whitespace / `--` line
1586/// comments / `/* … */` block comments and to read alphabetic
1587/// identifier keywords case-insensitively. It deliberately does
1588/// **not** try to parse the whole DDL — every operation past the
1589/// kind word is delegated to [`extract_owner_and_name`] working on
1590/// the upper-cased remainder.
1591struct Cursor<'a> {
1592    bytes: &'a [u8],
1593    pos: usize,
1594}
1595
1596impl<'a> Cursor<'a> {
1597    fn new(text: &'a str) -> Self {
1598        Self {
1599            bytes: text.as_bytes(),
1600            pos: 0,
1601        }
1602    }
1603
1604    fn skip_ws_and_comments(&mut self) {
1605        loop {
1606            // Skip ASCII whitespace.
1607            while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_whitespace() {
1608                self.pos += 1;
1609            }
1610            // `--` line comment.
1611            if self.pos + 1 < self.bytes.len()
1612                && self.bytes[self.pos].eq(&b'-')
1613                && self.bytes[self.pos + 1].eq(&b'-')
1614            {
1615                self.pos += 2;
1616                while self.pos < self.bytes.len() && self.bytes[self.pos].ne(&b'\n') {
1617                    self.pos += 1;
1618                }
1619                continue;
1620            }
1621            // `/* … */` block comment.
1622            if self.pos + 1 < self.bytes.len()
1623                && self.bytes[self.pos].eq(&b'/')
1624                && self.bytes[self.pos + 1].eq(&b'*')
1625            {
1626                self.pos += 2;
1627                while self.pos + 1 < self.bytes.len()
1628                    && !(self.bytes[self.pos].eq(&b'*') && self.bytes[self.pos + 1].eq(&b'/'))
1629                {
1630                    self.pos += 1;
1631                }
1632                if self.pos + 1 < self.bytes.len() {
1633                    self.pos += 2; // consume the closing `*/`
1634                } else {
1635                    self.pos = self.bytes.len(); // unterminated — end-of-input
1636                }
1637                continue;
1638            }
1639            break;
1640        }
1641    }
1642
1643    /// Returns true if the next identifier token matches `kw`
1644    /// case-insensitively (and is followed by a non-identifier
1645    /// character or end-of-input). Does not advance the cursor.
1646    fn peek_keyword(&self, kw: &str) -> bool {
1647        let end = self.pos + kw.len();
1648        if end > self.bytes.len() {
1649            return false;
1650        }
1651        if !self.bytes[self.pos..end].eq_ignore_ascii_case(kw.as_bytes()) {
1652            return false;
1653        }
1654        // Word boundary check — `CREATEDOC` must not match `CREATE`.
1655        if end < self.bytes.len() {
1656            let next = self.bytes[end];
1657            if next.eq(&b'_') || next.is_ascii_alphanumeric() {
1658                return false;
1659            }
1660        }
1661        true
1662    }
1663
1664    fn consume_keyword(&mut self, kw: &str) -> bool {
1665        if self.peek_keyword(kw) {
1666            self.pos += kw.len();
1667            true
1668        } else {
1669            false
1670        }
1671    }
1672
1673    /// Consume the next bare ASCII identifier (letters / digits /
1674    /// underscore, must start with a letter) and return it
1675    /// upper-cased. Returns `None` if the cursor is not on an
1676    /// identifier start character — e.g. a quoted identifier or a
1677    /// punctuation token. Quoted identifiers in the header position
1678    /// (the kind word) are not legal Oracle DDL so we don't bother.
1679    fn consume_identifier(&mut self) -> Option<String> {
1680        if self.pos >= self.bytes.len() {
1681            return None;
1682        }
1683        let first = self.bytes[self.pos];
1684        if !first.is_ascii_alphabetic() {
1685            return None;
1686        }
1687        let start = self.pos;
1688        while self.pos < self.bytes.len() {
1689            let b = self.bytes[self.pos];
1690            if b.is_ascii_alphanumeric() || b.eq(&b'_') {
1691                self.pos += 1;
1692            } else {
1693                break;
1694            }
1695        }
1696        let raw = std::str::from_utf8(&self.bytes[start..self.pos]).ok()?;
1697        Some(raw.to_ascii_uppercase())
1698    }
1699
1700    /// Return the rest of the input from the current cursor position,
1701    /// upper-cased. Used to hand off to [`extract_owner_and_name`].
1702    fn upper_remainder(&self) -> String {
1703        std::str::from_utf8(&self.bytes[self.pos..])
1704            .unwrap_or("")
1705            .to_ascii_uppercase()
1706    }
1707}
1708
1709/// Extract the optional `OWNER` and the bare `OBJECT` name from the
1710/// upper-cased remainder that follows the parsed `CREATE <KIND>`
1711/// header. Strips surrounding quotes (so `CREATE TABLE "HR"."EMP"`
1712/// works) and trailing punctuation / parenthesis that the column
1713/// list would attach. Operates on the post-header slice only — never
1714/// on the body — so it can't be fooled by `TABLE` appearing later.
1715fn extract_owner_and_name(after_kind: &str) -> Option<(Option<String>, String)> {
1716    let after = after_kind.trim_start();
1717
1718    // Scan the `[OWNER.]NAME` token honouring double-quoted Oracle
1719    // identifiers. A `"..."` segment is a single token that may contain
1720    // whitespace and runs to its closing `"`; an unquoted segment stops
1721    // at whitespace, `(`, `;`, or other DDL punctuation. The owner/name
1722    // split is the first top-level (outside-quotes) `.`.
1723    let mut segments: Vec<Segment> = Vec::new();
1724    let bytes = after.as_bytes();
1725    let mut i = 0usize;
1726    'scan: while i < bytes.len() {
1727        if bytes[i].eq(&b'"') {
1728            // Quoted segment: consume up to (and including) the closing `"`.
1729            let content_start = i + 1;
1730            let mut j = content_start;
1731            while j < bytes.len() && bytes[j].ne(&b'"') {
1732                j += 1;
1733            }
1734            // Unterminated quote ⇒ malformed header; give up.
1735            if j >= bytes.len() {
1736                return None;
1737            }
1738            segments.push(Segment {
1739                text: after[content_start..j].to_string(),
1740                quoted: true,
1741            });
1742            i = j + 1; // skip closing quote
1743        } else {
1744            // Unquoted run: identifier chars only. Anything else (space,
1745            // `(`, `;`, `,`, …) terminates the `[OWNER.]NAME` token —
1746            // except a top-level `.` which separates owner from name.
1747            let start = i;
1748            while i < bytes.len() {
1749                let c = bytes[i] as char;
1750                if c.is_ascii_alphanumeric() || c.eq(&'_') {
1751                    i += 1;
1752                } else {
1753                    break;
1754                }
1755            }
1756            // An empty unquoted run means we hit a non-identifier byte
1757            // that is not a segment separator: stop scanning the token.
1758            if i.eq(&start) {
1759                break 'scan;
1760            }
1761            segments.push(Segment {
1762                text: after[start..i].to_string(),
1763                quoted: false,
1764            });
1765        }
1766
1767        // After a segment, a `.` continues into the next (NAME) segment;
1768        // anything else ends the `[OWNER.]NAME` token.
1769        if i < bytes.len() && bytes[i].eq(&b'.') {
1770            i += 1;
1771        } else {
1772            break 'scan;
1773        }
1774    }
1775
1776    // Validate each segment: quoted segments accept any non-empty
1777    // content; unquoted segments must be a real identifier.
1778    let valid = |seg: &Segment| -> bool {
1779        if seg.text.is_empty() {
1780            return false;
1781        }
1782        seg.quoted || seg.text.chars().all(|c| c.is_alphanumeric() || c.eq(&'_'))
1783    };
1784
1785    match segments.as_slice() {
1786        [name] if valid(name) => Some((None, name.text.clone())),
1787        [owner, name] if valid(owner) && valid(name) => {
1788            Some((Some(owner.text.clone()), name.text.clone()))
1789        }
1790        _ => None,
1791    }
1792}
1793
1794/// One dot-delimited segment of a `[OWNER.]NAME` token, tracking whether
1795/// it originated from a double-quoted Oracle identifier (which may hold
1796/// whitespace and bypasses the unquoted identifier-char validity rule).
1797struct Segment {
1798    text: String,
1799    quoted: bool,
1800}
1801#[instrument(level = "trace", skip(cx, conn, request))]
1802pub async fn load_snapshot_from_connection<C: OracleConnection>(
1803    cx: &Cx,
1804    conn: &C,
1805    request: &CatalogLoadRequest,
1806) -> Result<CatalogSnapshot, CatalogError> {
1807    let connection_info = conn.describe(cx).await?;
1808    let resolved_schemas = resolve_schema_filters(&connection_info, request)?;
1809    let (oracle_version, version_warning) =
1810        oracle_version_from_server_version(&connection_info.server_version);
1811
1812    let mut capabilities = negotiate_capabilities(cx, conn).await;
1813    if let Some(warning) = version_warning {
1814        capabilities.warnings.push(warning);
1815    }
1816
1817    let mut snapshot = CatalogSnapshot::new(
1818        AnalysisProfile::for_version(oracle_version),
1819        capabilities,
1820        CatalogSource {
1821            kind: CatalogSourceKind::LiveConnection,
1822            path: None,
1823            description: Some(format!(
1824                "live extraction via {} from {}",
1825                connection_info.backend, connection_info.connect_string
1826            )),
1827        },
1828        Utc::now(),
1829    );
1830
1831    if let Some(current_schema) = connection_info.current_schema.as_deref() {
1832        snapshot.profile.current_schema = snapshot.intern_schema_name(current_schema);
1833    }
1834
1835    load_catalog_objects(cx, conn, &mut snapshot, &resolved_schemas).await?;
1836    load_catalog_columns(cx, conn, &mut snapshot, &resolved_schemas).await?;
1837    load_catalog_constraints(cx, conn, &mut snapshot, &resolved_schemas).await?;
1838    load_catalog_indexes(cx, conn, &mut snapshot, &resolved_schemas).await?;
1839    load_catalog_triggers(cx, conn, &mut snapshot, &resolved_schemas).await?;
1840    load_catalog_synonyms(cx, conn, &mut snapshot, &resolved_schemas).await?;
1841    load_catalog_routines(cx, conn, &mut snapshot, &resolved_schemas).await?;
1842    load_catalog_views(cx, conn, &mut snapshot, &resolved_schemas).await?;
1843    load_catalog_mviews(cx, conn, &mut snapshot, &resolved_schemas).await?;
1844    load_catalog_sequences(cx, conn, &mut snapshot, &resolved_schemas).await?;
1845    load_catalog_type_attrs(cx, conn, &mut snapshot, &resolved_schemas).await?;
1846    // Must precede grant extraction: ALL_TAB_PRIVS has no user/role
1847    // discriminator, so grantee classification consults `known_users`.
1848    load_catalog_users(cx, conn, &mut snapshot).await?;
1849    load_catalog_grants(cx, conn, &mut snapshot, &resolved_schemas).await?;
1850    load_catalog_db_links(cx, conn, &mut snapshot, &resolved_schemas).await?;
1851    load_catalog_table_comments(cx, conn, &mut snapshot, &resolved_schemas).await?;
1852    load_catalog_column_comments(cx, conn, &mut snapshot, &resolved_schemas).await?;
1853    load_catalog_editions(cx, conn, &mut snapshot).await?;
1854    load_catalog_editioning_views(cx, conn, &mut snapshot, &resolved_schemas).await?;
1855    load_catalog_vpd_policies(cx, conn, &mut snapshot, &resolved_schemas).await?;
1856    load_catalog_dependencies(cx, conn, &mut snapshot, &resolved_schemas).await?;
1857    if snapshot.capabilities.plscope_enabled {
1858        load_catalog_plscope_availability(cx, conn, &mut snapshot, &resolved_schemas).await?;
1859        load_catalog_plscope_identifiers(cx, conn, &mut snapshot, &resolved_schemas).await?;
1860    }
1861
1862    Ok(snapshot)
1863}
1864
1865#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
1866enum RoutineKind {
1867    Procedure,
1868    Function,
1869}
1870
1871#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
1872struct RoutineLocator {
1873    owner: SchemaName,
1874    package_name: Option<ObjectName>,
1875    routine_name: ObjectName,
1876    subprogram_id: Option<u32>,
1877    overload: Option<u32>,
1878}
1879
1880#[derive(Clone, Debug, Default)]
1881struct RoutineAccumulator {
1882    signature: Option<RoutineSignature>,
1883    kind_hint: Option<RoutineKind>,
1884    deterministic: bool,
1885    pipelined: bool,
1886}
1887
1888#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
1889pub struct SchemaCatalog {
1890    pub objects: HashMap<ObjectName, CatalogObject>,
1891    pub synonyms: HashMap<SynonymName, SynonymTarget>,
1892    pub grants: Vec<Grant>,
1893    pub indexes: HashMap<IndexName, IndexMetadata>,
1894    pub constraints: HashMap<ConstraintName, ConstraintMetadata>,
1895    pub triggers: HashMap<TriggerName, TriggerMetadata>,
1896    pub dependencies: Vec<CatalogDependency>,
1897    pub plscope: Option<PlScopeSnapshot>,
1898    /// Database links owned by this schema. Public links live in the
1899    /// synthetic `PUBLIC` schema. Sourced from `ALL_DB_LINKS`.
1900    #[serde(default)]
1901    pub db_links: Vec<DatabaseLink>,
1902    /// Per-object COMMENT ON TABLE / VIEW text. Sourced from
1903    /// `ALL_TAB_COMMENTS`.
1904    #[serde(default)]
1905    pub table_comments: Vec<TableComment>,
1906    /// Per-column COMMENT ON COLUMN text. Sourced from
1907    /// `ALL_COL_COMMENTS`.
1908    #[serde(default)]
1909    pub column_comments: Vec<ColumnComment>,
1910    /// Editioning views owned by this schema (the views that mask the
1911    /// underlying base table in an EBR shop). Sourced from
1912    /// `ALL_EDITIONING_VIEWS`.
1913    #[serde(default)]
1914    pub editioning_views: Vec<EditioningView>,
1915    /// VPD/RLS policies attached to objects in this schema. Sourced
1916    /// from `ALL_POLICIES`.
1917    #[serde(default)]
1918    pub vpd_policies: Vec<VpdPolicy>,
1919}
1920
1921fn resolve_schema_filters(
1922    connection_info: &OracleConnectionInfo,
1923    request: &CatalogLoadRequest,
1924) -> Result<Vec<String>, CatalogError> {
1925    let mut resolved = Vec::<String>::new();
1926
1927    for filter in &request.schema_filters {
1928        let schema_name = match filter {
1929            CatalogSchemaFilter::CurrentSchema => connection_info
1930                .current_schema
1931                .clone()
1932                .ok_or(CatalogError::CurrentSchemaUnavailable)?,
1933            CatalogSchemaFilter::Named(schema_name) => {
1934                let trimmed = schema_name.trim();
1935                if trimmed.is_empty() {
1936                    return Err(CatalogError::InvalidSchemaFilter {
1937                        schema_name: schema_name.clone(),
1938                    });
1939                }
1940                String::from(trimmed)
1941            }
1942        };
1943
1944        if !resolved.iter().any(|candidate| candidate.eq(&schema_name)) {
1945            resolved.push(schema_name);
1946        }
1947    }
1948
1949    if resolved.is_empty() {
1950        return Err(CatalogError::CurrentSchemaUnavailable);
1951    }
1952
1953    Ok(resolved)
1954}
1955
1956/// Fetch the canonical DDL + XML representation of a single object via
1957/// `DBMS_METADATA`.
1958///
1959/// Callers usually batch via [`populate_dbms_metadata_ddl`] which iterates
1960/// every object in a `CatalogSnapshot` after the structural loaders have
1961/// run. `object_type` must map to a DBMS_METADATA object type (see
1962/// [`object_type_to_dbms_metadata_value`]); unknown types return an
1963/// `Ok(None)` so the caller can continue without aborting the snapshot.
1964#[instrument(level = "trace", skip(cx, conn))]
1965pub async fn fetch_dbms_metadata_ddl<C: OracleConnection>(
1966    cx: &Cx,
1967    conn: &C,
1968    object_type: ObjectType,
1969    name: &str,
1970    owner: &str,
1971) -> Result<Option<DbmsMetadataDdl>, CatalogError> {
1972    let Some(dbms_type) = object_type_to_dbms_metadata_value(object_type) else {
1973        return Ok(None);
1974    };
1975    let sql = "select dbms_metadata.get_ddl(:1, :2, :3) as ddl_text, \
1976               dbms_metadata.get_xml(:1, :2, :3) as xml_text from dual";
1977    let params = vec![
1978        OracleBind::from(dbms_type.to_string()),
1979        OracleBind::from(name.to_string()),
1980        OracleBind::from(owner.to_string()),
1981    ];
1982    let rows = conn.query_rows(cx, sql, &params).await?;
1983    let Some(row) = rows.into_iter().next() else {
1984        return Ok(None);
1985    };
1986
1987    let ddl_text = match optional_nonblank_text(&row, "DDL_TEXT") {
1988        Some(value) => value.to_string(),
1989        None => return Ok(None),
1990    };
1991    let xml_text = optional_nonblank_text(&row, "XML_TEXT").map(String::from);
1992    let normalized_ddl = Some(normalize_dbms_metadata_ddl(&ddl_text));
1993
1994    Ok(Some(DbmsMetadataDdl {
1995        ddl_text,
1996        normalized_ddl,
1997        xml_text,
1998    }))
1999}
2000
2001/// Populate `ObjectCommon.ddl` for every object in the snapshot using
2002/// `DBMS_METADATA.GET_DDL` and `DBMS_METADATA.GET_XML`. Skips silently when
2003/// `capabilities.can_use_dbms_metadata` is false. Failures on individual
2004/// objects are recorded as `CapabilityWarning`s on the snapshot and do not
2005/// abort the populate pass.
2006#[instrument(level = "trace", skip(cx, conn, snapshot))]
2007pub async fn populate_dbms_metadata_ddl<C: OracleConnection>(
2008    cx: &Cx,
2009    conn: &C,
2010    snapshot: &mut CatalogSnapshot,
2011) -> Result<(), CatalogError> {
2012    if !snapshot.capabilities.can_use_dbms_metadata {
2013        return Ok(());
2014    }
2015
2016    let mut targets: Vec<(SchemaName, ObjectName, ObjectType, String, String)> = Vec::new();
2017    for (owner, schema) in &snapshot.schemas {
2018        let owner_name = snapshot
2019            .interner
2020            .resolve(owner.symbol())
2021            .unwrap_or("")
2022            .to_string();
2023        for (name, object) in &schema.objects {
2024            let common = catalog_object_common(object);
2025            let object_name = snapshot
2026                .interner
2027                .resolve(name.symbol())
2028                .unwrap_or("")
2029                .to_string();
2030            targets.push((
2031                *owner,
2032                *name,
2033                common.object_type,
2034                owner_name.clone(),
2035                object_name,
2036            ));
2037        }
2038    }
2039
2040    let mut warnings: Vec<CapabilityWarning> = Vec::new();
2041    let mut writes: Vec<(SchemaName, ObjectName, DbmsMetadataDdl)> = Vec::new();
2042    for (owner_symbol, name_symbol, object_type, owner_text, name_text) in targets {
2043        if owner_text.is_empty() || name_text.is_empty() {
2044            continue;
2045        }
2046        match fetch_dbms_metadata_ddl(cx, conn, object_type, &name_text, &owner_text).await {
2047            Ok(Some(ddl)) => writes.push((owner_symbol, name_symbol, ddl)),
2048            Ok(None) => {}
2049            Err(error) => warnings.push(CapabilityWarning {
2050                code: String::from("dbms-metadata-fetch-failed"),
2051                message: format!("DBMS_METADATA.GET_DDL({owner_text}.{name_text}) failed: {error}"),
2052                remediation: Some(String::from(
2053                    "Ensure DBMS_METADATA execute privilege is granted; the object may be wrapped or in an inaccessible edition.",
2054                )),
2055            }),
2056        }
2057    }
2058
2059    for (owner_symbol, name_symbol, ddl) in writes {
2060        if let Some(catalog_object) = snapshot
2061            .schemas
2062            .get_mut(&owner_symbol)
2063            .and_then(|schema| schema.objects.get_mut(&name_symbol))
2064        {
2065            set_catalog_object_ddl(catalog_object, ddl);
2066        }
2067    }
2068
2069    snapshot.capabilities.warnings.extend(warnings);
2070    Ok(())
2071}
2072
2073fn set_catalog_object_ddl(object: &mut CatalogObject, ddl: DbmsMetadataDdl) {
2074    match object {
2075        CatalogObject::Table(metadata) => metadata.common.ddl = Some(ddl),
2076        CatalogObject::View(metadata) => metadata.common.ddl = Some(ddl),
2077        CatalogObject::MaterializedView(metadata) => metadata.common.ddl = Some(ddl),
2078        CatalogObject::Sequence(metadata) => metadata.common.ddl = Some(ddl),
2079        CatalogObject::Type(metadata) => metadata.common.ddl = Some(ddl),
2080        CatalogObject::Package(metadata) => metadata.common.ddl = Some(ddl),
2081        CatalogObject::Procedure(metadata) => metadata.common.ddl = Some(ddl),
2082        CatalogObject::Function(metadata) => metadata.common.ddl = Some(ddl),
2083        CatalogObject::Trigger(metadata) => metadata.common.ddl = Some(ddl),
2084        CatalogObject::SchedulerJob(metadata) => metadata.common.ddl = Some(ddl),
2085        CatalogObject::EditioningView(metadata) => metadata.common.ddl = Some(ddl),
2086    }
2087}
2088
2089/// Normalize DDL text emitted by `DBMS_METADATA.GET_DDL` so equality checks
2090/// across runs ignore cosmetic differences:
2091///
2092/// - Trim leading + trailing whitespace.
2093/// - Collapse runs of whitespace inside the body to a single space (newlines
2094///   are preserved as-is so the result remains readable).
2095/// - Strip the trailing `/` SQL*Plus terminator if present.
2096#[must_use]
2097pub fn normalize_dbms_metadata_ddl(text: &str) -> String {
2098    let trimmed = text.trim();
2099    let trimmed = trimmed.strip_suffix('/').unwrap_or(trimmed).trim_end();
2100    let mut normalized = String::with_capacity(trimmed.len());
2101    let mut prev_space = false;
2102    for c in trimmed.chars() {
2103        if c.eq(&' ') || c.eq(&'\t') {
2104            if !prev_space {
2105                normalized.push(' ');
2106                prev_space = true;
2107            }
2108        } else {
2109            normalized.push(c);
2110            prev_space = false;
2111        }
2112    }
2113    normalized
2114}
2115
2116/// Map an `ObjectType` to the string the `DBMS_METADATA.GET_DDL` /
2117/// `GET_XML` overloads expect as their first parameter. Returns `None` for
2118/// types that have no DBMS_METADATA representation (e.g.
2119/// `ObjectType::Unknown`, `ObjectType::Constraint`).
2120#[must_use]
2121pub fn object_type_to_dbms_metadata_value(object_type: ObjectType) -> Option<&'static str> {
2122    match object_type {
2123        ObjectType::Table => Some("TABLE"),
2124        ObjectType::View => Some("VIEW"),
2125        ObjectType::MaterializedView => Some("MATERIALIZED_VIEW"),
2126        ObjectType::Sequence => Some("SEQUENCE"),
2127        ObjectType::Type => Some("TYPE"),
2128        ObjectType::Package => Some("PACKAGE"),
2129        ObjectType::Procedure => Some("PROCEDURE"),
2130        ObjectType::Function => Some("FUNCTION"),
2131        ObjectType::Trigger => Some("TRIGGER"),
2132        ObjectType::EditioningView => Some("VIEW"),
2133        ObjectType::SchedulerJob => Some("PROCOBJ"),
2134        ObjectType::Synonym => Some("SYNONYM"),
2135        ObjectType::Index => Some("INDEX"),
2136        ObjectType::Constraint | ObjectType::Unknown => None,
2137    }
2138}
2139
2140/// Probe an `OracleConnection` for the dictionary surface it can actually
2141/// reach. The loader records `CatalogCapabilities` from real probe outcomes
2142/// instead of optimistic defaults, so downstream consumers can render an
2143/// accurate doctor report and the right `MissingPermissionReport` rows.
2144///
2145/// The probes are intentionally cheap (`WHERE rownum = 0` / `BEGIN ... END`
2146/// blocks that no-op) and resilient to permission errors: each probe falls
2147/// back to `false` on any error, with a typed `CapabilityWarning` carrying
2148/// the probe name + Oracle error message + remediation hint.
2149#[must_use]
2150#[instrument(level = "trace", skip(cx, conn))]
2151pub async fn negotiate_capabilities<C: OracleConnection>(cx: &Cx, conn: &C) -> CatalogCapabilities {
2152    let mut capabilities = CatalogCapabilities {
2153        can_query_all_views: false,
2154        ..CatalogCapabilities::default()
2155    };
2156
2157    type CapabilitySetter = fn(&mut CatalogCapabilities);
2158    let probes: &[(&str, &str, &str, CapabilitySetter)] = &[
2159        (
2160            "select 1 from all_objects where rownum = 0",
2161            "all-views-probe",
2162            "ALL_OBJECTS unreachable; ensure the user has SELECT privilege on the standard ALL_* views.",
2163            |c| c.can_query_all_views = true,
2164        ),
2165        (
2166            "select 1 from dba_objects where rownum = 0",
2167            "dba-views-probe",
2168            "DBA_OBJECTS unreachable; grant SELECT_CATALOG_ROLE or specific DBA_* privileges to widen cross-schema coverage.",
2169            |c| c.can_query_dba_views = true,
2170        ),
2171        (
2172            "select 1 from all_source where rownum = 0",
2173            "all-source-probe",
2174            "ALL_SOURCE unreachable; ensure the user can read package/procedure bodies for source extraction.",
2175            |c| c.can_read_source = true,
2176        ),
2177        (
2178            "select 1 from all_scheduler_jobs where rownum = 0",
2179            "scheduler-probe",
2180            "ALL_SCHEDULER_JOBS unreachable; grant SELECT on the scheduler dictionary views to enable scheduler lineage.",
2181            |c| c.can_query_scheduler = true,
2182        ),
2183        (
2184            "select 1 from all_tab_privs where rownum = 0",
2185            "roles-and-grants-probe",
2186            "ALL_TAB_PRIVS unreachable; grant SELECT_CATALOG_ROLE to enable privilege chain analysis.",
2187            |c| c.can_query_roles_and_grants = true,
2188        ),
2189        (
2190            "select 1 from all_plsql_object_settings where rownum = 0",
2191            "plscope-probe",
2192            "ALL_PLSQL_OBJECT_SETTINGS unreachable; PL/Scope identifier extraction (PLSQL-CAT-010/011) will be unavailable.",
2193            |c| c.plscope_enabled = true,
2194        ),
2195    ];
2196
2197    for (sql, probe_code, remediation, setter) in probes {
2198        match conn.query_rows(cx, sql, &[]).await {
2199            Ok(_) => setter(&mut capabilities),
2200            Err(error) => capabilities.warnings.push(CapabilityWarning {
2201                code: String::from(*probe_code),
2202                message: format!("probe `{sql}` failed: {error}"),
2203                remediation: Some(String::from(*remediation)),
2204            }),
2205        }
2206    }
2207
2208    // DBMS_METADATA detection is a stored-procedure probe. Use an anonymous
2209    // PL/SQL block that bails out cheaply — `dbms_metadata.get_ddl` against a
2210    // guaranteed-existing object (`DUAL`) returns a CLOB without DDL side
2211    // effects.
2212    let dbms_metadata_probe =
2213        "begin if dbms_metadata.get_ddl('TABLE', 'DUAL', 'SYS') is null then null; end if; end;";
2214    match conn.execute(cx, dbms_metadata_probe, &[]).await {
2215        Ok(_) => {
2216            capabilities.can_use_dbms_metadata = true;
2217        }
2218        Err(error) => {
2219            capabilities.warnings.push(CapabilityWarning {
2220                code: String::from("dbms-metadata-probe"),
2221                message: format!("DBMS_METADATA probe failed: {error}"),
2222                remediation: Some(String::from(
2223                    "grant execute on DBMS_METADATA to <user> to enable PLSQL-CAT-015 DDL extraction.",
2224                )),
2225            });
2226        }
2227    }
2228
2229    capabilities
2230}
2231
2232fn oracle_version_from_server_version(
2233    server_version: &str,
2234) -> (OracleVersion, Option<CapabilityWarning>) {
2235    let major_component = server_version
2236        .split('.')
2237        .next()
2238        .unwrap_or_default()
2239        .trim()
2240        .parse::<u32>()
2241        .ok();
2242
2243    match major_component {
2244        Some(11) => (OracleVersion::Oracle11g, None),
2245        Some(12) => (OracleVersion::Oracle12c, None),
2246        Some(19) => (OracleVersion::Oracle19c, None),
2247        Some(21) => (OracleVersion::Oracle21c, None),
2248        Some(23) => (OracleVersion::Oracle23ai, None),
2249        Some(26) => (OracleVersion::Oracle26ai, None),
2250        _ => (
2251            OracleVersion::Oracle19c,
2252            Some(CapabilityWarning {
2253                code: String::from("catalog-version-parse-fallback"),
2254                message: format!(
2255                    "server version `{server_version}` did not map cleanly to a supported OracleVersion; defaulted AnalysisProfile.oracle_version to Oracle19c"
2256                ),
2257                remediation: Some(String::from(
2258                    "Set the workspace AnalysisProfile explicitly if this estate targets a newer or older Oracle release.",
2259                )),
2260            }),
2261        ),
2262    }
2263}
2264
2265async fn load_catalog_objects<C: OracleConnection>(
2266    cx: &Cx,
2267    conn: &C,
2268    snapshot: &mut CatalogSnapshot,
2269    schema_names: &[String],
2270) -> Result<(), CatalogError> {
2271    let owner_clause = oracle_bind_placeholders(schema_names.len(), 1);
2272    let sql = format!(
2273        "
2274select
2275  owner,
2276  object_name,
2277  object_type,
2278  status,
2279  to_char(last_ddl_time, 'YYYY-MM-DD\"T\"HH24:MI:SS') as last_ddl_time_iso,
2280  editionable,
2281  edition_name
2282from all_objects
2283where owner in ({owner_clause})
2284  and object_type in (
2285    'TABLE',
2286    'VIEW',
2287    'MATERIALIZED VIEW',
2288    'SEQUENCE',
2289    'TYPE',
2290    'PACKAGE',
2291    'PROCEDURE',
2292    'FUNCTION',
2293    'TRIGGER',
2294    'EDITIONING VIEW'
2295  )
2296order by owner, object_type, object_name
2297"
2298    );
2299    let params = schema_filter_params(schema_names);
2300
2301    for row in conn.query_rows(cx, &sql, &params).await? {
2302        apply_object_row(snapshot, &row)?;
2303    }
2304
2305    Ok(())
2306}
2307
2308async fn load_catalog_plscope_identifiers<C: OracleConnection>(
2309    cx: &Cx,
2310    conn: &C,
2311    snapshot: &mut CatalogSnapshot,
2312    schema_names: &[String],
2313) -> Result<(), CatalogError> {
2314    let owner_clause = oracle_bind_placeholders(schema_names.len(), 1);
2315    let sql = format!(
2316        "
2317select
2318  owner,
2319  name,
2320  type,
2321  usage,
2322  line,
2323  col,
2324  object_name
2325from all_identifiers
2326where owner in ({owner_clause})
2327order by owner, object_name, line, col
2328"
2329    );
2330    let params = schema_filter_params(schema_names);
2331    let rows = match conn.query_rows(cx, &sql, &params).await {
2332        Ok(rows) => rows,
2333        Err(error) => {
2334            snapshot.capabilities.warnings.push(CapabilityWarning {
2335                code: String::from("plscope-identifiers-failed"),
2336                message: format!("ALL_IDENTIFIERS query failed: {error}"),
2337                remediation: Some(String::from(
2338                    "Ensure the user can read ALL_IDENTIFIERS, or recompile target objects with `alter session set plscope_settings = 'identifiers:all'`.",
2339                )),
2340            });
2341            return Ok(());
2342        }
2343    };
2344
2345    for row in &rows {
2346        let Some(owner_text) = optional_nonblank_text(row, "OWNER") else {
2347            continue;
2348        };
2349        let Some(object_name_text) = optional_nonblank_text(row, "OBJECT_NAME") else {
2350            continue;
2351        };
2352        let Some(identifier_name_text) = optional_nonblank_text(row, "NAME") else {
2353            continue;
2354        };
2355        let Some(owner) = snapshot.intern_schema_name(owner_text) else {
2356            continue;
2357        };
2358        let Some(object_name) = snapshot.intern_object_name(object_name_text) else {
2359            continue;
2360        };
2361        let Some(identifier_name) = snapshot.intern_member_name(identifier_name_text) else {
2362            continue;
2363        };
2364        let identifier_type = optional_nonblank_text(row, "TYPE")
2365            .map(String::from)
2366            .unwrap_or_default();
2367        let usage = optional_nonblank_text(row, "USAGE")
2368            .map(String::from)
2369            .unwrap_or_default();
2370        let line = optional_u32(row, "LINE")?.unwrap_or(0);
2371        let column = optional_u32(row, "COL")?.unwrap_or(0);
2372
2373        let identifier = CompilerIdentifier {
2374            owner,
2375            object_name,
2376            identifier_name,
2377            identifier_type,
2378            usage,
2379            line,
2380            column,
2381        };
2382
2383        let plscope = snapshot
2384            .schemas
2385            .entry(owner)
2386            .or_default()
2387            .plscope
2388            .get_or_insert_with(|| PlScopeSnapshot {
2389                availability: PlScopeAvailability::IdentifiersOnly,
2390                collected_at: Some(snapshot.generated_at),
2391                ..PlScopeSnapshot::default()
2392            });
2393        plscope.identifiers.push(identifier);
2394    }
2395
2396    Ok(())
2397}
2398
2399async fn load_catalog_plscope_availability<C: OracleConnection>(
2400    cx: &Cx,
2401    conn: &C,
2402    snapshot: &mut CatalogSnapshot,
2403    schema_names: &[String],
2404) -> Result<(), CatalogError> {
2405    let owner_clause = oracle_bind_placeholders(schema_names.len(), 1);
2406    let sql = format!(
2407        "
2408select
2409  owner,
2410  plscope_settings
2411from all_plsql_object_settings
2412where owner in ({owner_clause})
2413"
2414    );
2415    let params = schema_filter_params(schema_names);
2416    let rows = match conn.query_rows(cx, &sql, &params).await {
2417        Ok(rows) => rows,
2418        Err(error) => {
2419            // Record the warning, leave per-schema plscope as None (the
2420            // default `PlScopeAvailability::NotAvailable`), and return Ok.
2421            snapshot.capabilities.warnings.push(CapabilityWarning {
2422                code: String::from("plscope-detect-failed"),
2423                message: format!("ALL_PLSQL_OBJECT_SETTINGS query failed: {error}"),
2424                remediation: Some(String::from(
2425                    "Grant SELECT on ALL_PLSQL_OBJECT_SETTINGS, or accept that PL/Scope detection is unavailable.",
2426                )),
2427            });
2428            return Ok(());
2429        }
2430    };
2431
2432    // Per-schema tallies: how many PLSQL units have IDENTIFIERS:* and how
2433    // many also carry STATEMENTS:*. The most informative observed setting
2434    // wins per-schema: STATEMENTS > IDENTIFIERS > NONE.
2435    let mut per_schema: HashMap<SchemaName, PlScopeTally> = HashMap::new();
2436    for row in &rows {
2437        let owner_text = match row.text("OWNER") {
2438            Some(value) if !value.trim().is_empty() => value,
2439            _ => continue,
2440        };
2441        let settings = row
2442            .text("PLSCOPE_SETTINGS")
2443            .unwrap_or("")
2444            .to_ascii_uppercase();
2445        let Some(owner) = snapshot.intern_schema_name(owner_text) else {
2446            continue;
2447        };
2448        let tally = per_schema.entry(owner).or_default();
2449        tally.total = tally.total.saturating_add(1);
2450        if settings.contains("STATEMENTS:") && !settings.contains("STATEMENTS:NONE") {
2451            tally.with_statements = tally.with_statements.saturating_add(1);
2452        }
2453        if settings.contains("IDENTIFIERS:") && !settings.contains("IDENTIFIERS:NONE") {
2454            tally.with_identifiers = tally.with_identifiers.saturating_add(1);
2455        }
2456    }
2457
2458    for (owner, tally) in per_schema {
2459        let availability = if tally.with_statements > 0 {
2460            PlScopeAvailability::IdentifiersAndStatements
2461        } else if tally.with_identifiers > 0 {
2462            PlScopeAvailability::IdentifiersOnly
2463        } else if tally.total > 0 {
2464            // PLSQL objects exist but none compiled with PL/Scope enabled.
2465            PlScopeAvailability::AvailableButStale
2466        } else {
2467            PlScopeAvailability::NotAvailable
2468        };
2469        let schema_catalog = snapshot.schemas.entry(owner).or_default();
2470        schema_catalog.plscope = Some(PlScopeSnapshot {
2471            availability,
2472            collected_at: Some(snapshot.generated_at),
2473            ..PlScopeSnapshot::default()
2474        });
2475    }
2476
2477    Ok(())
2478}
2479
2480#[derive(Default)]
2481struct PlScopeTally {
2482    total: usize,
2483    with_identifiers: usize,
2484    with_statements: usize,
2485}
2486
2487async fn load_catalog_dependencies<C: OracleConnection>(
2488    cx: &Cx,
2489    conn: &C,
2490    snapshot: &mut CatalogSnapshot,
2491    schema_names: &[String],
2492) -> Result<(), CatalogError> {
2493    let owner_clause = oracle_bind_placeholders(schema_names.len(), 1);
2494    let sql = format!(
2495        "
2496select
2497  owner,
2498  name,
2499  type,
2500  referenced_owner,
2501  referenced_name,
2502  referenced_type,
2503  dependency_type
2504from all_dependencies
2505where owner in ({owner_clause})
2506order by owner, name, referenced_owner, referenced_name
2507"
2508    );
2509    let params = schema_filter_params(schema_names);
2510
2511    for row in conn.query_rows(cx, &sql, &params).await? {
2512        apply_dependency_row(snapshot, &row)?;
2513    }
2514
2515    Ok(())
2516}
2517
2518async fn load_catalog_columns<C: OracleConnection>(
2519    cx: &Cx,
2520    conn: &C,
2521    snapshot: &mut CatalogSnapshot,
2522    schema_names: &[String],
2523) -> Result<(), CatalogError> {
2524    let owner_clause = oracle_bind_placeholders(schema_names.len(), 1);
2525    let sql = format!(
2526        "
2527select
2528  owner,
2529  table_name,
2530  column_name,
2531  nvl(column_id, internal_column_id) as column_position,
2532  data_type_owner,
2533  data_type,
2534  data_length,
2535  data_precision,
2536  data_scale,
2537  char_used,
2538  nullable,
2539  data_default_vc,
2540  virtual_column,
2541  hidden_column
2542from all_tab_cols
2543where owner in ({owner_clause})
2544order by owner, table_name, nvl(column_id, internal_column_id)
2545"
2546    );
2547    let params = schema_filter_params(schema_names);
2548
2549    for row in conn.query_rows(cx, &sql, &params).await? {
2550        apply_column_row(snapshot, &row)?;
2551    }
2552
2553    Ok(())
2554}
2555
2556async fn load_catalog_constraints<C: OracleConnection>(
2557    cx: &Cx,
2558    conn: &C,
2559    snapshot: &mut CatalogSnapshot,
2560    schema_names: &[String],
2561) -> Result<(), CatalogError> {
2562    let owner_clause = oracle_bind_placeholders(schema_names.len(), 1);
2563    let sql = format!(
2564        "
2565select
2566  c.owner,
2567  c.constraint_name,
2568  c.table_name,
2569  c.constraint_type,
2570  c.r_owner as referenced_table_owner,
2571  p.table_name as referenced_table_name,
2572  c.search_condition_vc,
2573  case when c.deferrable = 'DEFERRABLE' then 'Y' else 'N' end as is_deferrable,
2574  case when c.deferred = 'DEFERRED' then 'Y' else 'N' end as is_deferred,
2575  child.column_name,
2576  child.position as column_position,
2577  parent.column_name as referenced_column_name
2578from all_constraints c
2579left join all_constraints p
2580  on p.owner = c.r_owner
2581 and p.constraint_name = c.r_constraint_name
2582left join all_cons_columns child
2583  on child.owner = c.owner
2584 and child.constraint_name = c.constraint_name
2585left join all_cons_columns parent
2586  on parent.owner = p.owner
2587 and parent.constraint_name = p.constraint_name
2588 and parent.position = child.position
2589where c.owner in ({owner_clause})
2590  and c.constraint_type in ('P', 'R', 'U', 'C', 'F')
2591order by c.owner, c.constraint_name, child.position
2592"
2593    );
2594    let params = schema_filter_params(schema_names);
2595
2596    for row in conn.query_rows(cx, &sql, &params).await? {
2597        apply_constraint_row(snapshot, &row)?;
2598    }
2599
2600    Ok(())
2601}
2602
2603async fn load_catalog_indexes<C: OracleConnection>(
2604    cx: &Cx,
2605    conn: &C,
2606    snapshot: &mut CatalogSnapshot,
2607    schema_names: &[String],
2608) -> Result<(), CatalogError> {
2609    let owner_clause = oracle_bind_placeholders(schema_names.len(), 1);
2610    let sql = format!(
2611        "
2612select
2613  i.owner,
2614  i.index_name,
2615  i.table_owner,
2616  i.table_name,
2617  case when i.uniqueness = 'UNIQUE' then 'Y' else 'N' end as is_unique,
2618  i.index_type,
2619  i.status,
2620  c.column_name,
2621  c.column_position
2622from all_indexes i
2623left join all_ind_columns c
2624  on c.index_owner = i.owner
2625 and c.index_name = i.index_name
2626 and c.table_owner = i.table_owner
2627 and c.table_name = i.table_name
2628where i.owner in ({owner_clause})
2629order by i.owner, i.index_name, c.column_position
2630"
2631    );
2632    let params = schema_filter_params(schema_names);
2633
2634    for row in conn.query_rows(cx, &sql, &params).await? {
2635        apply_index_row(snapshot, &row)?;
2636    }
2637
2638    Ok(())
2639}
2640
2641async fn load_catalog_triggers<C: OracleConnection>(
2642    cx: &Cx,
2643    conn: &C,
2644    snapshot: &mut CatalogSnapshot,
2645    schema_names: &[String],
2646) -> Result<(), CatalogError> {
2647    let owner_clause = oracle_bind_placeholders(schema_names.len(), 1);
2648    let sql = format!(
2649        "
2650select
2651  owner,
2652  trigger_name,
2653  table_owner,
2654  table_name,
2655  trigger_type,
2656  triggering_event,
2657  when_clause
2658from all_triggers
2659where owner in ({owner_clause})
2660  and base_object_type in ('TABLE', 'VIEW')
2661order by owner, trigger_name
2662"
2663    );
2664    let params = schema_filter_params(schema_names);
2665
2666    for row in conn.query_rows(cx, &sql, &params).await? {
2667        apply_trigger_row(snapshot, &row)?;
2668    }
2669
2670    Ok(())
2671}
2672
2673async fn load_catalog_synonyms<C: OracleConnection>(
2674    cx: &Cx,
2675    conn: &C,
2676    snapshot: &mut CatalogSnapshot,
2677    schema_names: &[String],
2678) -> Result<(), CatalogError> {
2679    let owner_clause = oracle_bind_placeholders(schema_names.len(), 1);
2680    let sql = format!(
2681        "
2682select
2683  owner,
2684  synonym_name,
2685  table_owner,
2686  table_name,
2687  db_link
2688from all_synonyms
2689where owner = 'PUBLIC'
2690   or owner in ({owner_clause})
2691order by owner, synonym_name
2692"
2693    );
2694    let params = schema_filter_params(schema_names);
2695
2696    for row in conn.query_rows(cx, &sql, &params).await? {
2697        apply_synonym_row(snapshot, &row)?;
2698    }
2699
2700    Ok(())
2701}
2702
2703async fn load_catalog_routines<C: OracleConnection>(
2704    cx: &Cx,
2705    conn: &C,
2706    snapshot: &mut CatalogSnapshot,
2707    schema_names: &[String],
2708) -> Result<(), CatalogError> {
2709    let owner_clause = oracle_bind_placeholders(schema_names.len(), 1);
2710    let procedure_sql = format!(
2711        "
2712select
2713  owner,
2714  object_name,
2715  procedure_name,
2716  subprogram_id,
2717  overload,
2718  object_type,
2719  deterministic,
2720  pipelined
2721from all_procedures
2722where owner in ({owner_clause})
2723  and (procedure_name is not null or object_type in ('FUNCTION', 'PROCEDURE'))
2724order by owner, object_name, procedure_name, subprogram_id
2725"
2726    );
2727    let argument_sql = format!(
2728        "
2729select
2730  owner,
2731  package_name,
2732  object_name,
2733  subprogram_id,
2734  overload,
2735  argument_name,
2736  position,
2737  sequence,
2738  data_type,
2739  type_owner,
2740  type_name,
2741  data_length,
2742  data_precision,
2743  data_scale,
2744  in_out,
2745  defaulted
2746from all_arguments
2747where owner in ({owner_clause})
2748  and data_level = 0
2749order by owner, package_name, object_name, subprogram_id, sequence
2750"
2751    );
2752    let params = schema_filter_params(schema_names);
2753    let procedure_rows = conn.query_rows(cx, &procedure_sql, &params).await?;
2754    let argument_rows = conn.query_rows(cx, &argument_sql, &params).await?;
2755    let mut routines = HashMap::<RoutineLocator, RoutineAccumulator>::new();
2756
2757    for row in &procedure_rows {
2758        apply_routine_row(snapshot, row, &mut routines)?;
2759    }
2760    for row in &argument_rows {
2761        apply_argument_row(snapshot, row, &mut routines)?;
2762    }
2763
2764    finalize_routines(snapshot, routines)
2765}
2766
2767async fn load_catalog_views<C: OracleConnection>(
2768    cx: &Cx,
2769    conn: &C,
2770    snapshot: &mut CatalogSnapshot,
2771    schema_names: &[String],
2772) -> Result<(), CatalogError> {
2773    let owner_clause = oracle_bind_placeholders(schema_names.len(), 1);
2774    let sql = format!(
2775        "
2776select
2777  owner,
2778  view_name,
2779  text_vc,
2780  read_only
2781from all_views
2782where owner in ({owner_clause})
2783order by owner, view_name
2784"
2785    );
2786    let params = schema_filter_params(schema_names);
2787
2788    for row in conn.query_rows(cx, &sql, &params).await? {
2789        apply_view_row(snapshot, &row)?;
2790    }
2791
2792    Ok(())
2793}
2794
2795async fn load_catalog_mviews<C: OracleConnection>(
2796    cx: &Cx,
2797    conn: &C,
2798    snapshot: &mut CatalogSnapshot,
2799    schema_names: &[String],
2800) -> Result<(), CatalogError> {
2801    let owner_clause = oracle_bind_placeholders(schema_names.len(), 1);
2802    let sql = format!(
2803        "
2804select
2805  owner,
2806  mview_name,
2807  refresh_mode,
2808  refresh_method,
2809  query
2810from all_mviews
2811where owner in ({owner_clause})
2812order by owner, mview_name
2813"
2814    );
2815    let params = schema_filter_params(schema_names);
2816
2817    for row in conn.query_rows(cx, &sql, &params).await? {
2818        apply_mview_row(snapshot, &row)?;
2819    }
2820
2821    Ok(())
2822}
2823
2824async fn load_catalog_sequences<C: OracleConnection>(
2825    cx: &Cx,
2826    conn: &C,
2827    snapshot: &mut CatalogSnapshot,
2828    schema_names: &[String],
2829) -> Result<(), CatalogError> {
2830    let owner_clause = oracle_bind_placeholders(schema_names.len(), 1);
2831    let sql = format!(
2832        "
2833select
2834  sequence_owner,
2835  sequence_name,
2836  min_value,
2837  max_value,
2838  increment_by,
2839  cycle_flag,
2840  order_flag,
2841  cache_size
2842from all_sequences
2843where sequence_owner in ({owner_clause})
2844order by sequence_owner, sequence_name
2845"
2846    );
2847    let params = schema_filter_params(schema_names);
2848
2849    for row in conn.query_rows(cx, &sql, &params).await? {
2850        apply_sequence_row(snapshot, &row)?;
2851    }
2852
2853    Ok(())
2854}
2855
2856async fn load_catalog_type_attrs<C: OracleConnection>(
2857    cx: &Cx,
2858    conn: &C,
2859    snapshot: &mut CatalogSnapshot,
2860    schema_names: &[String],
2861) -> Result<(), CatalogError> {
2862    let owner_clause = oracle_bind_placeholders(schema_names.len(), 1);
2863    let sql = format!(
2864        "
2865select
2866  owner,
2867  type_name,
2868  attr_name,
2869  attr_no,
2870  attr_type_owner,
2871  attr_type_name,
2872  length,
2873  precision,
2874  scale
2875from all_type_attrs
2876where owner in ({owner_clause})
2877order by owner, type_name, attr_no
2878"
2879    );
2880    let params = schema_filter_params(schema_names);
2881
2882    for row in conn.query_rows(cx, &sql, &params).await? {
2883        apply_type_attr_row(snapshot, &row)?;
2884    }
2885
2886    Ok(())
2887}
2888
2889/// Load `ALL_DB_LINKS` rows into [`SchemaCatalog::db_links`].
2890///
2891/// Both private (owned by a user schema) and public (`OWNER = PUBLIC`)
2892/// links are fetched in a single query. The schema filter is applied as
2893/// `owner in ({schemas}) or owner = 'PUBLIC'` so public links always
2894/// surface — a remote reference can target a public link from any
2895/// schema and lineage needs that resolution to succeed.
2896async fn load_catalog_db_links<C: OracleConnection>(
2897    cx: &Cx,
2898    conn: &C,
2899    snapshot: &mut CatalogSnapshot,
2900    schema_names: &[String],
2901) -> Result<(), CatalogError> {
2902    let owner_clause = oracle_bind_placeholders(schema_names.len(), 1);
2903    let sql = format!(
2904        "
2905select
2906  owner,
2907  db_link,
2908  host
2909from all_db_links
2910where owner = 'PUBLIC'
2911   or owner in ({owner_clause})
2912order by owner, db_link
2913"
2914    );
2915    let params = schema_filter_params(schema_names);
2916
2917    for row in conn.query_rows(cx, &sql, &params).await? {
2918        apply_db_link_row(snapshot, &row)?;
2919    }
2920
2921    Ok(())
2922}
2923
2924/// Load `ALL_POLICIES` rows into [`SchemaCatalog::vpd_policies`].
2925/// One row per (object, policy_group, policy_name) triple. Filters to
2926/// enabled and disabled policies alike because lineage needs to know
2927/// about disabled ones as deployment-debt.
2928async fn load_catalog_vpd_policies<C: OracleConnection>(
2929    cx: &Cx,
2930    conn: &C,
2931    snapshot: &mut CatalogSnapshot,
2932    schema_names: &[String],
2933) -> Result<(), CatalogError> {
2934    let owner_clause = oracle_bind_placeholders(schema_names.len(), 1);
2935    let sql = format!(
2936        "
2937select
2938  object_owner,
2939  object_name,
2940  policy_group,
2941  policy_name,
2942  pf_owner,
2943  package,
2944  function,
2945  sel,
2946  ins,
2947  upd,
2948  del,
2949  enable
2950from all_policies
2951where object_owner in ({owner_clause})
2952order by object_owner, object_name, policy_group, policy_name
2953"
2954    );
2955    let params = schema_filter_params(schema_names);
2956
2957    for row in conn.query_rows(cx, &sql, &params).await? {
2958        apply_vpd_policy_row(snapshot, &row)?;
2959    }
2960    Ok(())
2961}
2962
2963/// Load `ALL_EDITIONS` into [`CatalogSnapshot::editions`]. The edition
2964/// tree is database-wide (not per-schema) so this loader takes no schema
2965/// filter.
2966async fn load_catalog_editions<C: OracleConnection>(
2967    cx: &Cx,
2968    conn: &C,
2969    snapshot: &mut CatalogSnapshot,
2970) -> Result<(), CatalogError> {
2971    let sql = "
2972select
2973  edition_name,
2974  parent_edition_name,
2975  usable
2976from all_editions
2977order by edition_name
2978";
2979    for row in conn.query_rows(cx, sql, &[]).await? {
2980        apply_edition_row(snapshot, &row)?;
2981    }
2982    Ok(())
2983}
2984
2985/// Load `ALL_EDITIONING_VIEWS` rows into
2986/// [`SchemaCatalog::editioning_views`].
2987async fn load_catalog_editioning_views<C: OracleConnection>(
2988    cx: &Cx,
2989    conn: &C,
2990    snapshot: &mut CatalogSnapshot,
2991    schema_names: &[String],
2992) -> Result<(), CatalogError> {
2993    let owner_clause = oracle_bind_placeholders(schema_names.len(), 1);
2994    let sql = format!(
2995        "
2996select
2997  owner,
2998  view_name,
2999  table_name
3000from all_editioning_views
3001where owner in ({owner_clause})
3002order by owner, view_name
3003"
3004    );
3005    let params = schema_filter_params(schema_names);
3006
3007    for row in conn.query_rows(cx, &sql, &params).await? {
3008        apply_editioning_view_row(snapshot, &row)?;
3009    }
3010    Ok(())
3011}
3012
3013/// Load `ALL_TAB_COMMENTS` rows into [`SchemaCatalog::table_comments`].
3014/// Filters NULL comments at the source to keep the snapshot compact.
3015async fn load_catalog_table_comments<C: OracleConnection>(
3016    cx: &Cx,
3017    conn: &C,
3018    snapshot: &mut CatalogSnapshot,
3019    schema_names: &[String],
3020) -> Result<(), CatalogError> {
3021    let owner_clause = oracle_bind_placeholders(schema_names.len(), 1);
3022    let sql = format!(
3023        "
3024select
3025  owner,
3026  table_name,
3027  table_type,
3028  comments
3029from all_tab_comments
3030where owner in ({owner_clause})
3031  and comments is not null
3032order by owner, table_name
3033"
3034    );
3035    let params = schema_filter_params(schema_names);
3036
3037    for row in conn.query_rows(cx, &sql, &params).await? {
3038        apply_table_comment_row(snapshot, &row)?;
3039    }
3040
3041    Ok(())
3042}
3043
3044/// Load `ALL_COL_COMMENTS` rows into [`SchemaCatalog::column_comments`].
3045/// Filters NULL comments at the source.
3046async fn load_catalog_column_comments<C: OracleConnection>(
3047    cx: &Cx,
3048    conn: &C,
3049    snapshot: &mut CatalogSnapshot,
3050    schema_names: &[String],
3051) -> Result<(), CatalogError> {
3052    let owner_clause = oracle_bind_placeholders(schema_names.len(), 1);
3053    let sql = format!(
3054        "
3055select
3056  owner,
3057  table_name,
3058  column_name,
3059  comments
3060from all_col_comments
3061where owner in ({owner_clause})
3062  and comments is not null
3063order by owner, table_name, column_name
3064"
3065    );
3066    let params = schema_filter_params(schema_names);
3067
3068    for row in conn.query_rows(cx, &sql, &params).await? {
3069        apply_column_comment_row(snapshot, &row)?;
3070    }
3071
3072    Ok(())
3073}
3074
3075/// Populate [`CatalogSnapshot::known_users`] from `ALL_USERS`.
3076///
3077/// `ALL_USERS` is readable by `PUBLIC` on a stock Oracle instance, so this
3078/// needs no `SELECT_CATALOG_ROLE` / DBA grant. The resulting set lets
3079/// [`grantee_from_dictionary_value`] discriminate object-privilege grantees
3080/// (whose `ALL_TAB_PRIVS.GRANTEE` carries no user/role type column) into
3081/// real users versus database roles.
3082///
3083/// Failure is non-fatal: if `ALL_USERS` cannot be read, the snapshot keeps
3084/// `known_users` set to `None` (an explicit "undetermined" state, R13) and records
3085/// a [`CapabilityWarning`] rather than aborting the extraction. Callers must
3086/// invoke this BEFORE [`load_catalog_grants`].
3087async fn load_catalog_users<C: OracleConnection>(
3088    cx: &Cx,
3089    conn: &C,
3090    snapshot: &mut CatalogSnapshot,
3091) -> Result<(), CatalogError> {
3092    let sql = "select username from all_users order by username";
3093    match conn.query_rows(cx, sql, &[]).await {
3094        Ok(rows) => {
3095            let mut users = HashSet::with_capacity(rows.len());
3096            for row in &rows {
3097                let username = row.require_text("USERNAME")?;
3098                let Some(user) = snapshot.intern_user_name(username) else {
3099                    return Err(CatalogError::InvalidColumnValue {
3100                        column: String::from("USERNAME"),
3101                        expected: "interned user name",
3102                        value: String::from(username),
3103                    });
3104                };
3105                users.insert(user);
3106            }
3107            snapshot.known_users = Some(users);
3108        }
3109        Err(error) => {
3110            // R13: do not fail the snapshot and do not silently pretend the
3111            // grantee universe is known. Leave `known_users` as `None` so
3112            // grantee classification stays conservative downstream.
3113            snapshot.known_users = None;
3114            snapshot.capabilities.warnings.push(CapabilityWarning {
3115                code: String::from("all-users-probe"),
3116                message: format!("ALL_USERS read failed: {error}"),
3117                remediation: Some(String::from(
3118                    "ensure the analysis user can SELECT ALL_USERS so object grants to roles are not misclassified as direct user grants.",
3119                )),
3120            });
3121        }
3122    }
3123    Ok(())
3124}
3125
3126async fn load_catalog_grants<C: OracleConnection>(
3127    cx: &Cx,
3128    conn: &C,
3129    snapshot: &mut CatalogSnapshot,
3130    schema_names: &[String],
3131) -> Result<(), CatalogError> {
3132    let owner_clause = oracle_bind_placeholders(schema_names.len(), 1);
3133    let sql = format!(
3134        "
3135select
3136  table_schema,
3137  table_name,
3138  grantee,
3139  privilege,
3140  grantable,
3141  hierarchy
3142from all_tab_privs
3143where table_schema in ({owner_clause})
3144order by table_schema, table_name, grantee, privilege
3145"
3146    );
3147    let params = schema_filter_params(schema_names);
3148
3149    for row in conn.query_rows(cx, &sql, &params).await? {
3150        apply_grant_row(snapshot, &row)?;
3151    }
3152
3153    Ok(())
3154}
3155
3156fn oracle_bind_placeholders(count: usize, start_index: usize) -> String {
3157    (0..count)
3158        .map(|offset| format!(":{}", start_index + offset))
3159        .collect::<Vec<_>>()
3160        .join(", ")
3161}
3162
3163fn hash_text(text: &str) -> Hash {
3164    use sha2::{Digest as _, Sha256};
3165    let mut hasher = Sha256::new();
3166    hasher.update(text.as_bytes());
3167    // sha2 0.11+ returns `Array<u8, …>` from `finalize` which no
3168    // longer impls `LowerHex` directly; render byte-by-byte (matches
3169    // the plsql-store pattern). Keeps the bump from being a breaking
3170    // change for callers.
3171    let digest = hasher.finalize();
3172    let mut rendered = String::with_capacity(7 + digest.len() * 2);
3173    rendered.push_str("sha256:");
3174    for byte in digest {
3175        rendered.push_str(&format!("{byte:02x}"));
3176    }
3177    Hash::new(rendered)
3178}
3179
3180fn schema_filter_params(schema_names: &[String]) -> Vec<OracleBind> {
3181    schema_names
3182        .iter()
3183        .cloned()
3184        .map(OracleBind::from)
3185        .collect::<Vec<_>>()
3186}
3187
3188fn apply_object_row(snapshot: &mut CatalogSnapshot, row: &OracleRow) -> Result<(), CatalogError> {
3189    let owner_text = row.require_text("OWNER")?;
3190    let object_name_text = row.require_text("OBJECT_NAME")?;
3191    let object_type_text = row.require_text("OBJECT_TYPE")?;
3192    let Some(object_type) = object_type_from_dictionary_value(object_type_text) else {
3193        return Ok(());
3194    };
3195
3196    let Some(owner) = snapshot.intern_schema_name(owner_text) else {
3197        return Err(CatalogError::InvalidColumnValue {
3198            column: String::from("OWNER"),
3199            expected: "interned schema name",
3200            value: String::from(owner_text),
3201        });
3202    };
3203    let Some(object_name) = snapshot.intern_object_name(object_name_text) else {
3204        return Err(CatalogError::InvalidColumnValue {
3205            column: String::from("OBJECT_NAME"),
3206            expected: "interned object name",
3207            value: String::from(object_name_text),
3208        });
3209    };
3210
3211    let last_ddl_time =
3212        optional_nonblank_text(row, "LAST_DDL_TIME_ISO").and_then(parse_dictionary_timestamp);
3213    let editionable = optional_bool(row, "EDITIONABLE")?;
3214    let edition_name = optional_nonblank_text(row, "EDITION_NAME")
3215        .map(|value| {
3216            snapshot
3217                .interner
3218                .intern(value)
3219                .map(EditionName::from)
3220                .ok_or(CatalogError::InvalidColumnValue {
3221                    column: String::from("EDITION_NAME"),
3222                    expected: "interned edition name",
3223                    value: String::from(value),
3224                })
3225        })
3226        .transpose()?;
3227
3228    let common = ObjectCommon {
3229        owner,
3230        name: object_name,
3231        object_type,
3232        status: row
3233            .text("STATUS")
3234            .map(object_status_from_dictionary_value)
3235            .unwrap_or_default(),
3236        edition_name,
3237        editionable,
3238        last_ddl_time,
3239        ..ObjectCommon::default()
3240    };
3241
3242    let Some(catalog_object) = blank_catalog_object(common) else {
3243        return Ok(());
3244    };
3245
3246    snapshot
3247        .schemas
3248        .entry(owner)
3249        .or_default()
3250        .objects
3251        .insert(object_name, catalog_object);
3252
3253    Ok(())
3254}
3255
3256fn apply_dependency_row(
3257    snapshot: &mut CatalogSnapshot,
3258    row: &OracleRow,
3259) -> Result<(), CatalogError> {
3260    let owner_text = row.require_text("OWNER")?;
3261    let name_text = row.require_text("NAME")?;
3262    let referenced_owner_text = row.require_text("REFERENCED_OWNER")?;
3263    let referenced_name_text = row.require_text("REFERENCED_NAME")?;
3264
3265    let Some(owner) = snapshot.intern_schema_name(owner_text) else {
3266        return Err(CatalogError::InvalidColumnValue {
3267            column: String::from("OWNER"),
3268            expected: "interned schema name",
3269            value: String::from(owner_text),
3270        });
3271    };
3272    let Some(object_name) = snapshot.intern_object_name(name_text) else {
3273        return Err(CatalogError::InvalidColumnValue {
3274            column: String::from("NAME"),
3275            expected: "interned object name",
3276            value: String::from(name_text),
3277        });
3278    };
3279    let Some(referenced_owner) = snapshot.intern_schema_name(referenced_owner_text) else {
3280        return Err(CatalogError::InvalidColumnValue {
3281            column: String::from("REFERENCED_OWNER"),
3282            expected: "interned schema name",
3283            value: String::from(referenced_owner_text),
3284        });
3285    };
3286    let Some(referenced_name) = snapshot.intern_object_name(referenced_name_text) else {
3287        return Err(CatalogError::InvalidColumnValue {
3288            column: String::from("REFERENCED_NAME"),
3289            expected: "interned object name",
3290            value: String::from(referenced_name_text),
3291        });
3292    };
3293
3294    let object_type = optional_nonblank_text(row, "TYPE")
3295        .and_then(object_type_from_dictionary_value)
3296        .unwrap_or_default();
3297    let referenced_type =
3298        optional_nonblank_text(row, "REFERENCED_TYPE").and_then(object_type_from_dictionary_value);
3299
3300    let dependency = CatalogDependency {
3301        owner,
3302        name: object_name,
3303        object_type,
3304        referenced_owner: Some(referenced_owner),
3305        referenced_name,
3306        referenced_type,
3307        dependency_kind: optional_nonblank_text(row, "DEPENDENCY_TYPE")
3308            .map(catalog_dependency_kind_from_dictionary_value)
3309            .unwrap_or_default(),
3310        via_db_link: None,
3311    };
3312
3313    snapshot
3314        .schemas
3315        .entry(owner)
3316        .or_default()
3317        .dependencies
3318        .push(dependency);
3319
3320    Ok(())
3321}
3322
3323fn parse_dictionary_timestamp(text: &str) -> Option<DateTime<Utc>> {
3324    // Expected shape from the loader query: `YYYY-MM-DD"T"HH24:MI:SS`.
3325    chrono::NaiveDateTime::parse_from_str(text, "%Y-%m-%dT%H:%M:%S")
3326        .ok()
3327        .map(|naive| DateTime::<Utc>::from_naive_utc_and_offset(naive, Utc))
3328}
3329
3330fn catalog_dependency_kind_from_dictionary_value(text: &str) -> CatalogDependencyKind {
3331    match text.to_ascii_uppercase().as_str() {
3332        "HARD" => CatalogDependencyKind::Hard,
3333        "REF" => CatalogDependencyKind::Reference,
3334        "EXTENDED" => CatalogDependencyKind::Extended,
3335        _ => CatalogDependencyKind::default(),
3336    }
3337}
3338
3339fn apply_column_row(snapshot: &mut CatalogSnapshot, row: &OracleRow) -> Result<(), CatalogError> {
3340    let owner_text = row.require_text("OWNER")?;
3341    let table_name_text = row.require_text("TABLE_NAME")?;
3342    let column_name_text = row.require_text("COLUMN_NAME")?;
3343
3344    let Some(owner) = snapshot.intern_schema_name(owner_text) else {
3345        return Err(CatalogError::InvalidColumnValue {
3346            column: String::from("OWNER"),
3347            expected: "interned schema name",
3348            value: String::from(owner_text),
3349        });
3350    };
3351    let Some(table_name) = snapshot.intern_object_name(table_name_text) else {
3352        return Err(CatalogError::InvalidColumnValue {
3353            column: String::from("TABLE_NAME"),
3354            expected: "interned object name",
3355            value: String::from(table_name_text),
3356        });
3357    };
3358    let Some(column_name) = snapshot.intern_column_name(column_name_text) else {
3359        return Err(CatalogError::InvalidColumnValue {
3360            column: String::from("COLUMN_NAME"),
3361            expected: "interned column name",
3362            value: String::from(column_name_text),
3363        });
3364    };
3365    let data_type = data_type_ref_from_row(snapshot, row)?;
3366
3367    let Some(schema_catalog) = snapshot.schemas.get_mut(&owner) else {
3368        return Ok(());
3369    };
3370    let Some(catalog_object) = schema_catalog.objects.get_mut(&table_name) else {
3371        return Ok(());
3372    };
3373
3374    let default_expression = row
3375        .text("DATA_DEFAULT_VC")
3376        .map(String::from)
3377        .filter(|value| !value.trim().is_empty());
3378    let virtual_column = optional_bool(row, "VIRTUAL_COLUMN")?.unwrap_or(false);
3379    let column = ColumnMetadata {
3380        name: column_name,
3381        position: required_u32(row, "COLUMN_POSITION")?,
3382        data_type,
3383        nullable: optional_bool(row, "NULLABLE")?.unwrap_or(false),
3384        default_expression: if virtual_column {
3385            None
3386        } else {
3387            default_expression.clone()
3388        },
3389        generated_expression: if virtual_column {
3390            default_expression
3391        } else {
3392            None
3393        },
3394        hidden: optional_bool(row, "HIDDEN_COLUMN")?.unwrap_or(false),
3395    };
3396
3397    match catalog_object {
3398        CatalogObject::Table(metadata) => {
3399            metadata.columns.insert(column.name, column);
3400        }
3401        CatalogObject::View(metadata) => {
3402            metadata.columns.insert(column.name, column);
3403        }
3404        CatalogObject::MaterializedView(metadata) => {
3405            metadata.columns.insert(column.name, column);
3406        }
3407        CatalogObject::EditioningView(metadata) => {
3408            metadata.columns.insert(column.name, column);
3409        }
3410        CatalogObject::Sequence(_)
3411        | CatalogObject::Type(_)
3412        | CatalogObject::Package(_)
3413        | CatalogObject::Procedure(_)
3414        | CatalogObject::Function(_)
3415        | CatalogObject::Trigger(_)
3416        | CatalogObject::SchedulerJob(_) => {}
3417    }
3418
3419    Ok(())
3420}
3421
3422fn apply_constraint_row(
3423    snapshot: &mut CatalogSnapshot,
3424    row: &OracleRow,
3425) -> Result<(), CatalogError> {
3426    let owner_text = row.require_text("OWNER")?;
3427    let constraint_name_text = row.require_text("CONSTRAINT_NAME")?;
3428    let table_name_text = row.require_text("TABLE_NAME")?;
3429    let search_condition = optional_nonblank_text(row, "SEARCH_CONDITION_VC").map(String::from);
3430
3431    let Some(owner) = snapshot.intern_schema_name(owner_text) else {
3432        return Err(CatalogError::InvalidColumnValue {
3433            column: String::from("OWNER"),
3434            expected: "interned schema name",
3435            value: String::from(owner_text),
3436        });
3437    };
3438    let Some(constraint_name) = snapshot.intern_constraint_name(constraint_name_text) else {
3439        return Err(CatalogError::InvalidColumnValue {
3440            column: String::from("CONSTRAINT_NAME"),
3441            expected: "interned constraint name",
3442            value: String::from(constraint_name_text),
3443        });
3444    };
3445    let Some(table_name) = snapshot.intern_object_name(table_name_text) else {
3446        return Err(CatalogError::InvalidColumnValue {
3447            column: String::from("TABLE_NAME"),
3448            expected: "interned object name",
3449            value: String::from(table_name_text),
3450        });
3451    };
3452    let referenced_table_owner = optional_nonblank_text(row, "REFERENCED_TABLE_OWNER")
3453        .map(|value| {
3454            snapshot
3455                .intern_schema_name(value)
3456                .ok_or(CatalogError::InvalidColumnValue {
3457                    column: String::from("REFERENCED_TABLE_OWNER"),
3458                    expected: "interned schema name",
3459                    value: String::from(value),
3460                })
3461        })
3462        .transpose()?;
3463    let referenced_table_name = optional_nonblank_text(row, "REFERENCED_TABLE_NAME")
3464        .map(|value| {
3465            snapshot
3466                .intern_object_name(value)
3467                .ok_or(CatalogError::InvalidColumnValue {
3468                    column: String::from("REFERENCED_TABLE_NAME"),
3469                    expected: "interned object name",
3470                    value: String::from(value),
3471                })
3472        })
3473        .transpose()?;
3474    let child_column = optional_nonblank_text(row, "COLUMN_NAME")
3475        .map(|value| {
3476            snapshot
3477                .intern_column_name(value)
3478                .ok_or(CatalogError::InvalidColumnValue {
3479                    column: String::from("COLUMN_NAME"),
3480                    expected: "interned column name",
3481                    value: String::from(value),
3482                })
3483        })
3484        .transpose()?;
3485    let referenced_column = optional_nonblank_text(row, "REFERENCED_COLUMN_NAME")
3486        .map(|value| {
3487            snapshot
3488                .intern_column_name(value)
3489                .ok_or(CatalogError::InvalidColumnValue {
3490                    column: String::from("REFERENCED_COLUMN_NAME"),
3491                    expected: "interned column name",
3492                    value: String::from(value),
3493                })
3494        })
3495        .transpose()?;
3496
3497    let constraint_type = constraint_type_from_dictionary_value(
3498        row.require_text("CONSTRAINT_TYPE")?,
3499        search_condition.as_deref(),
3500        child_column.is_some(),
3501    );
3502
3503    let metadata = snapshot
3504        .schemas
3505        .entry(owner)
3506        .or_default()
3507        .constraints
3508        .entry(constraint_name)
3509        .or_insert_with(|| ConstraintMetadata {
3510            name: constraint_name,
3511            table_owner: owner,
3512            table_name,
3513            constraint_type,
3514            columns: Vec::new(),
3515            referenced_table_owner,
3516            referenced_table_name,
3517            referenced_columns: Vec::new(),
3518            search_condition: search_condition.clone(),
3519            deferrable: optional_bool(row, "IS_DEFERRABLE").ok().flatten(),
3520            initially_deferred: optional_bool(row, "IS_DEFERRED").ok().flatten(),
3521        });
3522
3523    metadata.table_name = table_name;
3524    metadata.constraint_type = constraint_type;
3525    metadata.referenced_table_owner = referenced_table_owner;
3526    metadata.referenced_table_name = referenced_table_name;
3527    metadata.search_condition = search_condition;
3528    metadata.deferrable = optional_bool(row, "IS_DEFERRABLE")?;
3529    metadata.initially_deferred = optional_bool(row, "IS_DEFERRED")?;
3530
3531    if let Some(column_name) = child_column {
3532        push_unique_column(&mut metadata.columns, column_name);
3533    }
3534    if let Some(column_name) = referenced_column {
3535        push_unique_column(&mut metadata.referenced_columns, column_name);
3536    }
3537
3538    Ok(())
3539}
3540
3541fn apply_index_row(snapshot: &mut CatalogSnapshot, row: &OracleRow) -> Result<(), CatalogError> {
3542    let owner_text = row.require_text("OWNER")?;
3543    let index_name_text = row.require_text("INDEX_NAME")?;
3544    let table_owner_text = row.require_text("TABLE_OWNER")?;
3545    let table_name_text = row.require_text("TABLE_NAME")?;
3546
3547    let Some(owner) = snapshot.intern_schema_name(owner_text) else {
3548        return Err(CatalogError::InvalidColumnValue {
3549            column: String::from("OWNER"),
3550            expected: "interned schema name",
3551            value: String::from(owner_text),
3552        });
3553    };
3554    let Some(index_name) = snapshot.intern_index_name(index_name_text) else {
3555        return Err(CatalogError::InvalidColumnValue {
3556            column: String::from("INDEX_NAME"),
3557            expected: "interned index name",
3558            value: String::from(index_name_text),
3559        });
3560    };
3561    let Some(table_owner) = snapshot.intern_schema_name(table_owner_text) else {
3562        return Err(CatalogError::InvalidColumnValue {
3563            column: String::from("TABLE_OWNER"),
3564            expected: "interned schema name",
3565            value: String::from(table_owner_text),
3566        });
3567    };
3568    let Some(table_name) = snapshot.intern_object_name(table_name_text) else {
3569        return Err(CatalogError::InvalidColumnValue {
3570            column: String::from("TABLE_NAME"),
3571            expected: "interned object name",
3572            value: String::from(table_name_text),
3573        });
3574    };
3575    let index_column = optional_nonblank_text(row, "COLUMN_NAME")
3576        .map(|value| {
3577            snapshot
3578                .intern_column_name(value)
3579                .ok_or(CatalogError::InvalidColumnValue {
3580                    column: String::from("COLUMN_NAME"),
3581                    expected: "interned column name",
3582                    value: String::from(value),
3583                })
3584        })
3585        .transpose()?;
3586
3587    let metadata = snapshot
3588        .schemas
3589        .entry(owner)
3590        .or_default()
3591        .indexes
3592        .entry(index_name)
3593        .or_insert_with(|| IndexMetadata {
3594            name: index_name,
3595            table_owner,
3596            table_name,
3597            unique: optional_bool(row, "IS_UNIQUE")
3598                .ok()
3599                .flatten()
3600                .unwrap_or(false),
3601            columns: Vec::new(),
3602            index_type: String::from(row.text("INDEX_TYPE").unwrap_or_default()),
3603            status: row
3604                .text("STATUS")
3605                .map(object_status_from_dictionary_value)
3606                .unwrap_or_default(),
3607        });
3608
3609    metadata.table_owner = table_owner;
3610    metadata.table_name = table_name;
3611    metadata.unique = optional_bool(row, "IS_UNIQUE")?.unwrap_or(false);
3612    metadata.index_type = String::from(row.text("INDEX_TYPE").unwrap_or_default());
3613    metadata.status = row
3614        .text("STATUS")
3615        .map(object_status_from_dictionary_value)
3616        .unwrap_or_default();
3617
3618    if let Some(column_name) = index_column {
3619        push_unique_column(&mut metadata.columns, column_name);
3620    }
3621
3622    Ok(())
3623}
3624
3625fn apply_trigger_row(snapshot: &mut CatalogSnapshot, row: &OracleRow) -> Result<(), CatalogError> {
3626    let owner_text = row.require_text("OWNER")?;
3627    let trigger_name_text = row.require_text("TRIGGER_NAME")?;
3628    let table_owner_text = row.require_text("TABLE_OWNER")?;
3629    let table_name_text = row.require_text("TABLE_NAME")?;
3630
3631    let Some(owner) = snapshot.intern_schema_name(owner_text) else {
3632        return Err(CatalogError::InvalidColumnValue {
3633            column: String::from("OWNER"),
3634            expected: "interned schema name",
3635            value: String::from(owner_text),
3636        });
3637    };
3638    let Some(trigger_name) = snapshot.intern_trigger_name(trigger_name_text) else {
3639        return Err(CatalogError::InvalidColumnValue {
3640            column: String::from("TRIGGER_NAME"),
3641            expected: "interned trigger name",
3642            value: String::from(trigger_name_text),
3643        });
3644    };
3645    let Some(object_name) = snapshot.intern_object_name(trigger_name_text) else {
3646        return Err(CatalogError::InvalidColumnValue {
3647            column: String::from("TRIGGER_NAME"),
3648            expected: "interned object name",
3649            value: String::from(trigger_name_text),
3650        });
3651    };
3652    let Some(target_owner) = snapshot.intern_schema_name(table_owner_text) else {
3653        return Err(CatalogError::InvalidColumnValue {
3654            column: String::from("TABLE_OWNER"),
3655            expected: "interned schema name",
3656            value: String::from(table_owner_text),
3657        });
3658    };
3659    let Some(target_name) = snapshot.intern_object_name(table_name_text) else {
3660        return Err(CatalogError::InvalidColumnValue {
3661            column: String::from("TABLE_NAME"),
3662            expected: "interned object name",
3663            value: String::from(table_name_text),
3664        });
3665    };
3666
3667    let schema_catalog = snapshot.schemas.entry(owner).or_default();
3668    let common = schema_catalog
3669        .objects
3670        .get(&object_name)
3671        .and_then(|object| {
3672            if let CatalogObject::Trigger(metadata) = object {
3673                Some(metadata.common.clone())
3674            } else {
3675                None
3676            }
3677        })
3678        .unwrap_or_else(|| ObjectCommon {
3679            owner,
3680            name: object_name,
3681            object_type: ObjectType::Trigger,
3682            ..ObjectCommon::default()
3683        });
3684
3685    let metadata = TriggerMetadata {
3686        common,
3687        target_owner,
3688        target_name,
3689        timing: trigger_timing_from_dictionary_value(row.text("TRIGGER_TYPE").unwrap_or_default()),
3690        level: trigger_level_from_dictionary_value(row.text("TRIGGER_TYPE").unwrap_or_default()),
3691        events: trigger_events_from_dictionary_value(
3692            row.text("TRIGGERING_EVENT").unwrap_or_default(),
3693        ),
3694        when_clause: optional_nonblank_text(row, "WHEN_CLAUSE").map(String::from),
3695        body_hash: None,
3696    };
3697
3698    schema_catalog
3699        .triggers
3700        .insert(trigger_name, metadata.clone());
3701    schema_catalog
3702        .objects
3703        .insert(object_name, CatalogObject::Trigger(metadata));
3704
3705    Ok(())
3706}
3707
3708fn apply_synonym_row(snapshot: &mut CatalogSnapshot, row: &OracleRow) -> Result<(), CatalogError> {
3709    let owner_text = row.require_text("OWNER")?;
3710    let synonym_name_text = row.require_text("SYNONYM_NAME")?;
3711    let target_name_text = row.require_text("TABLE_NAME")?;
3712
3713    let Some(owner) = snapshot.intern_schema_name(owner_text) else {
3714        return Err(CatalogError::InvalidColumnValue {
3715            column: String::from("OWNER"),
3716            expected: "interned schema name",
3717            value: String::from(owner_text),
3718        });
3719    };
3720    let Some(synonym_name) = snapshot.intern_synonym_name(synonym_name_text) else {
3721        return Err(CatalogError::InvalidColumnValue {
3722            column: String::from("SYNONYM_NAME"),
3723            expected: "interned synonym name",
3724            value: String::from(synonym_name_text),
3725        });
3726    };
3727    let Some(target_name) = snapshot.intern_object_name(target_name_text) else {
3728        return Err(CatalogError::InvalidColumnValue {
3729            column: String::from("TABLE_NAME"),
3730            expected: "interned object name",
3731            value: String::from(target_name_text),
3732        });
3733    };
3734    let target_owner = optional_nonblank_text(row, "TABLE_OWNER")
3735        .map(|value| {
3736            snapshot
3737                .intern_schema_name(value)
3738                .ok_or(CatalogError::InvalidColumnValue {
3739                    column: String::from("TABLE_OWNER"),
3740                    expected: "interned schema name",
3741                    value: String::from(value),
3742                })
3743        })
3744        .transpose()?;
3745
3746    snapshot.schemas.entry(owner).or_default().synonyms.insert(
3747        synonym_name,
3748        SynonymTarget {
3749            target_owner,
3750            target_name,
3751            target_type: None,
3752            db_link: optional_nonblank_text(row, "DB_LINK").map(String::from),
3753            public_synonym: owner_text.eq_ignore_ascii_case("PUBLIC"),
3754        },
3755    );
3756
3757    Ok(())
3758}
3759
3760fn apply_view_row(snapshot: &mut CatalogSnapshot, row: &OracleRow) -> Result<(), CatalogError> {
3761    let owner_text = row.require_text("OWNER")?;
3762    let view_name_text = row.require_text("VIEW_NAME")?;
3763
3764    let Some(owner) = snapshot.intern_schema_name(owner_text) else {
3765        return Err(CatalogError::InvalidColumnValue {
3766            column: String::from("OWNER"),
3767            expected: "interned schema name",
3768            value: String::from(owner_text),
3769        });
3770    };
3771    let Some(view_name) = snapshot.intern_object_name(view_name_text) else {
3772        return Err(CatalogError::InvalidColumnValue {
3773            column: String::from("VIEW_NAME"),
3774            expected: "interned object name",
3775            value: String::from(view_name_text),
3776        });
3777    };
3778
3779    let query_hash = optional_nonblank_text(row, "TEXT_VC").map(hash_text);
3780    let read_only = optional_bool(row, "READ_ONLY")?;
3781
3782    let Some(schema_catalog) = snapshot.schemas.get_mut(&owner) else {
3783        return Ok(());
3784    };
3785    let Some(catalog_object) = schema_catalog.objects.get_mut(&view_name) else {
3786        return Ok(());
3787    };
3788
3789    if let CatalogObject::View(metadata) = catalog_object {
3790        metadata.query_hash = query_hash;
3791        metadata.read_only = read_only;
3792    }
3793
3794    Ok(())
3795}
3796
3797fn apply_mview_row(snapshot: &mut CatalogSnapshot, row: &OracleRow) -> Result<(), CatalogError> {
3798    let owner_text = row.require_text("OWNER")?;
3799    let mview_name_text = row.require_text("MVIEW_NAME")?;
3800
3801    let Some(owner) = snapshot.intern_schema_name(owner_text) else {
3802        return Err(CatalogError::InvalidColumnValue {
3803            column: String::from("OWNER"),
3804            expected: "interned schema name",
3805            value: String::from(owner_text),
3806        });
3807    };
3808    let Some(mview_name) = snapshot.intern_object_name(mview_name_text) else {
3809        return Err(CatalogError::InvalidColumnValue {
3810            column: String::from("MVIEW_NAME"),
3811            expected: "interned object name",
3812            value: String::from(mview_name_text),
3813        });
3814    };
3815
3816    let refresh_mode = optional_nonblank_text(row, "REFRESH_MODE").map(String::from);
3817    let refresh_method = optional_nonblank_text(row, "REFRESH_METHOD").map(String::from);
3818    let query_hash = optional_nonblank_text(row, "QUERY").map(hash_text);
3819
3820    let Some(schema_catalog) = snapshot.schemas.get_mut(&owner) else {
3821        return Ok(());
3822    };
3823    let Some(catalog_object) = schema_catalog.objects.get_mut(&mview_name) else {
3824        return Ok(());
3825    };
3826
3827    if let CatalogObject::MaterializedView(metadata) = catalog_object {
3828        metadata.refresh_mode = refresh_mode;
3829        metadata.refresh_method = refresh_method;
3830        metadata.query_hash = query_hash;
3831    }
3832
3833    Ok(())
3834}
3835
3836fn apply_sequence_row(snapshot: &mut CatalogSnapshot, row: &OracleRow) -> Result<(), CatalogError> {
3837    let owner_text = row.require_text("SEQUENCE_OWNER")?;
3838    let sequence_name_text = row.require_text("SEQUENCE_NAME")?;
3839
3840    let Some(owner) = snapshot.intern_schema_name(owner_text) else {
3841        return Err(CatalogError::InvalidColumnValue {
3842            column: String::from("SEQUENCE_OWNER"),
3843            expected: "interned schema name",
3844            value: String::from(owner_text),
3845        });
3846    };
3847    let Some(sequence_name) = snapshot.intern_object_name(sequence_name_text) else {
3848        return Err(CatalogError::InvalidColumnValue {
3849            column: String::from("SEQUENCE_NAME"),
3850            expected: "interned object name",
3851            value: String::from(sequence_name_text),
3852        });
3853    };
3854
3855    let increment_by = row.parse_i64("INCREMENT_BY").unwrap_or(1);
3856    let min_value = row.parse_i64("MIN_VALUE").ok();
3857    let max_value = row.parse_i64("MAX_VALUE").ok();
3858    let cycle = row
3859        .text("CYCLE_FLAG")
3860        .map(|value| value.eq_ignore_ascii_case("Y"))
3861        .unwrap_or(false);
3862    let ordered = row
3863        .text("ORDER_FLAG")
3864        .map(|value| value.eq_ignore_ascii_case("Y"))
3865        .unwrap_or(false);
3866    let cache_size = row.parse_u64("CACHE_SIZE").ok();
3867
3868    let Some(schema_catalog) = snapshot.schemas.get_mut(&owner) else {
3869        return Ok(());
3870    };
3871    let Some(catalog_object) = schema_catalog.objects.get_mut(&sequence_name) else {
3872        return Ok(());
3873    };
3874
3875    if let CatalogObject::Sequence(metadata) = catalog_object {
3876        metadata.increment_by = increment_by;
3877        metadata.min_value = min_value;
3878        metadata.max_value = max_value;
3879        metadata.cycle = cycle;
3880        metadata.ordered = ordered;
3881        metadata.cache_size = cache_size;
3882    }
3883
3884    Ok(())
3885}
3886
3887fn apply_type_attr_row(
3888    snapshot: &mut CatalogSnapshot,
3889    row: &OracleRow,
3890) -> Result<(), CatalogError> {
3891    let owner_text = row.require_text("OWNER")?;
3892    let type_name_text = row.require_text("TYPE_NAME")?;
3893    let attr_name_text = row.require_text("ATTR_NAME")?;
3894
3895    let Some(owner) = snapshot.intern_schema_name(owner_text) else {
3896        return Err(CatalogError::InvalidColumnValue {
3897            column: String::from("OWNER"),
3898            expected: "interned schema name",
3899            value: String::from(owner_text),
3900        });
3901    };
3902    let Some(type_name) = snapshot.intern_object_name(type_name_text) else {
3903        return Err(CatalogError::InvalidColumnValue {
3904            column: String::from("TYPE_NAME"),
3905            expected: "interned object name",
3906            value: String::from(type_name_text),
3907        });
3908    };
3909    let Some(attr_name) = snapshot.intern_member_name(attr_name_text) else {
3910        return Err(CatalogError::InvalidColumnValue {
3911            column: String::from("ATTR_NAME"),
3912            expected: "interned member name",
3913            value: String::from(attr_name_text),
3914        });
3915    };
3916
3917    let attr_type_owner = optional_nonblank_text(row, "ATTR_TYPE_OWNER")
3918        .map(|value| {
3919            snapshot
3920                .intern_schema_name(value)
3921                .ok_or(CatalogError::InvalidColumnValue {
3922                    column: String::from("ATTR_TYPE_OWNER"),
3923                    expected: "interned schema name",
3924                    value: String::from(value),
3925                })
3926        })
3927        .transpose()?;
3928    let attr_type_name = row
3929        .text("ATTR_TYPE_NAME")
3930        .map(String::from)
3931        .unwrap_or_default();
3932
3933    let attribute = TypeAttribute {
3934        name: attr_name,
3935        position: required_u32(row, "ATTR_NO")?,
3936        data_type: DataTypeRef {
3937            owner: attr_type_owner,
3938            name: attr_type_name,
3939            length: optional_u32(row, "LENGTH")?,
3940            precision: optional_u32(row, "PRECISION")?,
3941            scale: optional_i32(row, "SCALE")?,
3942            char_semantics: None,
3943        },
3944    };
3945
3946    let Some(schema_catalog) = snapshot.schemas.get_mut(&owner) else {
3947        return Ok(());
3948    };
3949    let Some(catalog_object) = schema_catalog.objects.get_mut(&type_name) else {
3950        return Ok(());
3951    };
3952
3953    if let CatalogObject::Type(metadata) = catalog_object {
3954        match metadata
3955            .attributes
3956            .iter()
3957            .position(|existing| existing.position.eq(&attribute.position))
3958        {
3959            Some(index) => metadata.attributes[index] = attribute,
3960            None => metadata.attributes.push(attribute),
3961        }
3962        metadata
3963            .attributes
3964            .sort_by_key(|attribute| attribute.position);
3965    }
3966
3967    Ok(())
3968}
3969
3970/// Apply a single `ALL_DB_LINKS` row into the snapshot. Ensures the
3971/// owning schema entry exists (lazily creates it) so a `PUBLIC` row
3972/// lands even when no other catalog object has been recorded for that
3973/// synthetic schema.
3974fn apply_db_link_row(snapshot: &mut CatalogSnapshot, row: &OracleRow) -> Result<(), CatalogError> {
3975    let owner_text = row.require_text("OWNER")?;
3976    let link_name_text = row.require_text("DB_LINK")?;
3977
3978    let Some(owner) = snapshot.intern_schema_name(owner_text) else {
3979        return Err(CatalogError::InvalidColumnValue {
3980            column: String::from("OWNER"),
3981            expected: "interned schema name",
3982            value: String::from(owner_text),
3983        });
3984    };
3985
3986    let host = optional_nonblank_text(row, "HOST").map(String::from);
3987    let public_link = owner_text.eq_ignore_ascii_case("PUBLIC");
3988
3989    let schema_catalog = snapshot.schemas.entry(owner).or_default();
3990    schema_catalog.db_links.push(DatabaseLink {
3991        owner,
3992        name: String::from(link_name_text),
3993        host,
3994        public_link,
3995    });
3996
3997    Ok(())
3998}
3999
4000/// Apply a single `ALL_POLICIES` row.
4001fn apply_vpd_policy_row(
4002    snapshot: &mut CatalogSnapshot,
4003    row: &OracleRow,
4004) -> Result<(), CatalogError> {
4005    let object_owner_text = row.require_text("OBJECT_OWNER")?;
4006    let object_name_text = row.require_text("OBJECT_NAME")?;
4007    let policy_name = row.require_text("POLICY_NAME")?.to_string();
4008    let function_owner_text = row.require_text("PF_OWNER")?;
4009    let function_name = row.require_text("FUNCTION")?.to_string();
4010
4011    let Some(object_owner) = snapshot.intern_schema_name(object_owner_text) else {
4012        return Err(CatalogError::InvalidColumnValue {
4013            column: String::from("OBJECT_OWNER"),
4014            expected: "interned schema name",
4015            value: String::from(object_owner_text),
4016        });
4017    };
4018    let Some(object_name) = snapshot.intern_object_name(object_name_text) else {
4019        return Err(CatalogError::InvalidColumnValue {
4020            column: String::from("OBJECT_NAME"),
4021            expected: "interned object name",
4022            value: String::from(object_name_text),
4023        });
4024    };
4025    let Some(function_owner) = snapshot.intern_schema_name(function_owner_text) else {
4026        return Err(CatalogError::InvalidColumnValue {
4027            column: String::from("PF_OWNER"),
4028            expected: "interned schema name",
4029            value: String::from(function_owner_text),
4030        });
4031    };
4032
4033    let policy_group = optional_nonblank_text(row, "POLICY_GROUP").map(String::from);
4034    let function_package = optional_nonblank_text(row, "PACKAGE").map(String::from);
4035
4036    let yn = |col: &str| {
4037        row.text(col)
4038            .map(|v| v.eq_ignore_ascii_case("Y") || v.eq_ignore_ascii_case("YES"))
4039            .unwrap_or(false)
4040    };
4041
4042    let schema_catalog = snapshot.schemas.entry(object_owner).or_default();
4043    schema_catalog.vpd_policies.push(VpdPolicy {
4044        object_owner,
4045        object_name,
4046        policy_group,
4047        policy_name,
4048        function_owner,
4049        function_package,
4050        function_name,
4051        on_select: yn("SEL"),
4052        on_insert: yn("INS"),
4053        on_update: yn("UPD"),
4054        on_delete: yn("DEL"),
4055        enabled: yn("ENABLE"),
4056    });
4057    Ok(())
4058}
4059
4060/// Apply a single `ALL_EDITIONS` row.
4061fn apply_edition_row(snapshot: &mut CatalogSnapshot, row: &OracleRow) -> Result<(), CatalogError> {
4062    let edition_name = row.require_text("EDITION_NAME")?.to_string();
4063    let parent_edition_name = optional_nonblank_text(row, "PARENT_EDITION_NAME").map(String::from);
4064    let usable = row
4065        .text("USABLE")
4066        .map(|v| v.eq_ignore_ascii_case("Y"))
4067        .unwrap_or(true);
4068    snapshot.editions.push(Edition {
4069        edition_name,
4070        parent_edition_name,
4071        usable,
4072    });
4073    Ok(())
4074}
4075
4076/// Apply a single `ALL_EDITIONING_VIEWS` row.
4077fn apply_editioning_view_row(
4078    snapshot: &mut CatalogSnapshot,
4079    row: &OracleRow,
4080) -> Result<(), CatalogError> {
4081    let owner_text = row.require_text("OWNER")?;
4082    let view_name_text = row.require_text("VIEW_NAME")?;
4083    let table_name_text = row.require_text("TABLE_NAME")?;
4084
4085    let Some(owner) = snapshot.intern_schema_name(owner_text) else {
4086        return Err(CatalogError::InvalidColumnValue {
4087            column: String::from("OWNER"),
4088            expected: "interned schema name",
4089            value: String::from(owner_text),
4090        });
4091    };
4092    let Some(view_name) = snapshot.intern_object_name(view_name_text) else {
4093        return Err(CatalogError::InvalidColumnValue {
4094            column: String::from("VIEW_NAME"),
4095            expected: "interned object name",
4096            value: String::from(view_name_text),
4097        });
4098    };
4099    let Some(table_name) = snapshot.intern_object_name(table_name_text) else {
4100        return Err(CatalogError::InvalidColumnValue {
4101            column: String::from("TABLE_NAME"),
4102            expected: "interned object name",
4103            value: String::from(table_name_text),
4104        });
4105    };
4106
4107    let schema_catalog = snapshot.schemas.entry(owner).or_default();
4108    schema_catalog.editioning_views.push(EditioningView {
4109        owner,
4110        view_name,
4111        table_name,
4112    });
4113    Ok(())
4114}
4115
4116/// Apply a single `ALL_TAB_COMMENTS` row into the snapshot.
4117fn apply_table_comment_row(
4118    snapshot: &mut CatalogSnapshot,
4119    row: &OracleRow,
4120) -> Result<(), CatalogError> {
4121    let owner_text = row.require_text("OWNER")?;
4122    let table_name_text = row.require_text("TABLE_NAME")?;
4123    let table_type = row.text("TABLE_TYPE").map(String::from).unwrap_or_default();
4124    let comments = row.require_text("COMMENTS")?.to_string();
4125
4126    let Some(owner) = snapshot.intern_schema_name(owner_text) else {
4127        return Err(CatalogError::InvalidColumnValue {
4128            column: String::from("OWNER"),
4129            expected: "interned schema name",
4130            value: String::from(owner_text),
4131        });
4132    };
4133    let Some(table_name) = snapshot.intern_object_name(table_name_text) else {
4134        return Err(CatalogError::InvalidColumnValue {
4135            column: String::from("TABLE_NAME"),
4136            expected: "interned object name",
4137            value: String::from(table_name_text),
4138        });
4139    };
4140
4141    let schema_catalog = snapshot.schemas.entry(owner).or_default();
4142    schema_catalog.table_comments.push(TableComment {
4143        owner,
4144        table_name,
4145        table_type,
4146        comments,
4147    });
4148    Ok(())
4149}
4150
4151/// Apply a single `ALL_COL_COMMENTS` row into the snapshot.
4152fn apply_column_comment_row(
4153    snapshot: &mut CatalogSnapshot,
4154    row: &OracleRow,
4155) -> Result<(), CatalogError> {
4156    let owner_text = row.require_text("OWNER")?;
4157    let table_name_text = row.require_text("TABLE_NAME")?;
4158    let column_name_text = row.require_text("COLUMN_NAME")?;
4159    let comments = row.require_text("COMMENTS")?.to_string();
4160
4161    let Some(owner) = snapshot.intern_schema_name(owner_text) else {
4162        return Err(CatalogError::InvalidColumnValue {
4163            column: String::from("OWNER"),
4164            expected: "interned schema name",
4165            value: String::from(owner_text),
4166        });
4167    };
4168    let Some(table_name) = snapshot.intern_object_name(table_name_text) else {
4169        return Err(CatalogError::InvalidColumnValue {
4170            column: String::from("TABLE_NAME"),
4171            expected: "interned object name",
4172            value: String::from(table_name_text),
4173        });
4174    };
4175    let Some(column_name) = snapshot.intern_column_name(column_name_text) else {
4176        return Err(CatalogError::InvalidColumnValue {
4177            column: String::from("COLUMN_NAME"),
4178            expected: "interned column name",
4179            value: String::from(column_name_text),
4180        });
4181    };
4182
4183    let schema_catalog = snapshot.schemas.entry(owner).or_default();
4184    schema_catalog.column_comments.push(ColumnComment {
4185        owner,
4186        table_name,
4187        column_name,
4188        comments,
4189    });
4190    Ok(())
4191}
4192
4193fn apply_grant_row(snapshot: &mut CatalogSnapshot, row: &OracleRow) -> Result<(), CatalogError> {
4194    let owner_text = row.require_text("TABLE_SCHEMA")?;
4195    let object_name_text = row.require_text("TABLE_NAME")?;
4196    let grantee_text = row.require_text("GRANTEE")?;
4197    let privilege_text = row.require_text("PRIVILEGE")?;
4198
4199    let Some(owner) = snapshot.intern_schema_name(owner_text) else {
4200        return Err(CatalogError::InvalidColumnValue {
4201            column: String::from("TABLE_SCHEMA"),
4202            expected: "interned schema name",
4203            value: String::from(owner_text),
4204        });
4205    };
4206    let Some(object_name) = snapshot.intern_object_name(object_name_text) else {
4207        return Err(CatalogError::InvalidColumnValue {
4208            column: String::from("TABLE_NAME"),
4209            expected: "interned object name",
4210            value: String::from(object_name_text),
4211        });
4212    };
4213
4214    let grantee = grantee_from_dictionary_value(snapshot, grantee_text)?;
4215    let privilege = grant_privilege_from_dictionary_value(privilege_text);
4216    let grantable = row
4217        .text("GRANTABLE")
4218        .map(|value| value.eq_ignore_ascii_case("YES"))
4219        .unwrap_or(false);
4220    let with_hierarchy = row
4221        .text("HIERARCHY")
4222        .map(|value| value.eq_ignore_ascii_case("YES"))
4223        .unwrap_or(false);
4224
4225    let grant = Grant {
4226        object_owner: owner,
4227        object_name,
4228        privilege,
4229        grantee,
4230        grantable,
4231        via_role: None,
4232        with_hierarchy,
4233    };
4234
4235    let schema_catalog = snapshot.schemas.entry(owner).or_default();
4236    if !schema_catalog
4237        .grants
4238        .iter()
4239        .any(|existing| existing.eq(&grant))
4240    {
4241        schema_catalog.grants.push(grant);
4242    }
4243
4244    Ok(())
4245}
4246
4247/// Classify an `ALL_TAB_PRIVS.GRANTEE` value into a [`Grantee`].
4248///
4249/// `ALL_TAB_PRIVS` carries no user/role discriminator column, so the
4250/// grantee universe — `{ user, role, PUBLIC }` — is resolved against the
4251/// `ALL_USERS`-derived [`CatalogSnapshot::known_users`] set:
4252///
4253/// * `PUBLIC` -> [`Grantee::Public`].
4254/// * known username -> [`Grantee::User`] (a statically certain direct grant).
4255/// * loaded set, name absent -> [`Grantee::Role`] (the only remaining class;
4256///   the resolver then caps it at Low confidence and emits a
4257///   `RuntimeGrantOrRole` ambiguity because a role grant only applies when
4258///   the role is enabled in `SESSION_ROLES` at runtime).
4259/// * username set NOT loaded (`known_users` is `None`) -> [`Grantee::Role`]
4260///   as well. This is the R13 fail-toward-restrictive choice: when the
4261///   grantee class is genuinely undetermined we must NOT default to a
4262///   high-confidence direct user grant (a fail-toward-permissive result in
4263///   a privilege/SAST product); treating it as a role routes it through the
4264///   runtime-ambiguity downgrade instead of over-claiming certainty.
4265fn grantee_from_dictionary_value(
4266    snapshot: &mut CatalogSnapshot,
4267    text: &str,
4268) -> Result<Grantee, CatalogError> {
4269    if text.eq_ignore_ascii_case("PUBLIC") {
4270        return Ok(Grantee::Public);
4271    }
4272    let Some(symbol) = snapshot.interner.intern(text) else {
4273        return Err(CatalogError::InvalidColumnValue {
4274            column: String::from("GRANTEE"),
4275            expected: "interned grantee name",
4276            value: String::from(text),
4277        });
4278    };
4279    let is_known_user = snapshot
4280        .known_users
4281        .as_ref()
4282        .is_some_and(|users| users.contains(&UserName::from(symbol)));
4283    if is_known_user {
4284        Ok(Grantee::User(UserName::from(symbol)))
4285    } else {
4286        Ok(Grantee::Role(RoleName::from(symbol)))
4287    }
4288}
4289
4290fn grant_privilege_from_dictionary_value(text: &str) -> GrantPrivilege {
4291    match text.to_ascii_uppercase().as_str() {
4292        "SELECT" => GrantPrivilege::Select,
4293        "INSERT" => GrantPrivilege::Insert,
4294        "UPDATE" => GrantPrivilege::Update,
4295        "DELETE" => GrantPrivilege::Delete,
4296        "EXECUTE" => GrantPrivilege::Execute,
4297        "ALTER" => GrantPrivilege::Alter,
4298        "INDEX" => GrantPrivilege::Index,
4299        "REFERENCES" => GrantPrivilege::References,
4300        "DEBUG" => GrantPrivilege::Debug,
4301        _ => GrantPrivilege::Other,
4302    }
4303}
4304
4305fn apply_routine_row(
4306    snapshot: &mut CatalogSnapshot,
4307    row: &OracleRow,
4308    routines: &mut HashMap<RoutineLocator, RoutineAccumulator>,
4309) -> Result<(), CatalogError> {
4310    let locator = routine_locator_from_procedure_row(snapshot, row)?;
4311    let deterministic = optional_bool(row, "DETERMINISTIC")?.unwrap_or(false);
4312    let pipelined = optional_bool(row, "PIPELINED")?.unwrap_or(false);
4313    let kind_hint = routine_kind_from_dictionary_value(optional_nonblank_text(row, "OBJECT_TYPE"));
4314
4315    let accumulator = routines.entry(locator).or_default();
4316    accumulator
4317        .signature
4318        .get_or_insert_with(|| RoutineSignature {
4319            routine_name: locator.routine_name,
4320            overload: locator.overload,
4321            ..RoutineSignature::default()
4322        });
4323    accumulator.kind_hint = kind_hint.or(accumulator.kind_hint);
4324    accumulator.deterministic = deterministic;
4325    accumulator.pipelined = pipelined;
4326
4327    Ok(())
4328}
4329
4330fn apply_argument_row(
4331    snapshot: &mut CatalogSnapshot,
4332    row: &OracleRow,
4333    routines: &mut HashMap<RoutineLocator, RoutineAccumulator>,
4334) -> Result<(), CatalogError> {
4335    let locator = routine_locator_from_argument_row(snapshot, row)?;
4336    let data_type = data_type_ref_from_argument_row(snapshot, row)?;
4337    let accumulator = routines.entry(locator).or_default();
4338    let signature = accumulator
4339        .signature
4340        .get_or_insert_with(|| RoutineSignature {
4341            routine_name: locator.routine_name,
4342            overload: locator.overload,
4343            ..RoutineSignature::default()
4344        });
4345    let position = required_u32(row, "POSITION")?;
4346
4347    if position.eq(&0) {
4348        signature.return_type = Some(data_type);
4349        accumulator.kind_hint = Some(RoutineKind::Function);
4350        return Ok(());
4351    }
4352
4353    signature.arguments.push(ArgumentMetadata {
4354        position,
4355        name: optional_nonblank_text(row, "ARGUMENT_NAME")
4356            .map(|value| {
4357                snapshot
4358                    .intern_member_name(value)
4359                    .ok_or(CatalogError::InvalidColumnValue {
4360                        column: String::from("ARGUMENT_NAME"),
4361                        expected: "interned member name",
4362                        value: String::from(value),
4363                    })
4364            })
4365            .transpose()?,
4366        mode: parameter_mode_from_dictionary_value(row.text("IN_OUT")),
4367        data_type,
4368        defaulted: optional_bool(row, "DEFAULTED")?.unwrap_or(false),
4369    });
4370
4371    Ok(())
4372}
4373
4374fn finalize_routines(
4375    snapshot: &mut CatalogSnapshot,
4376    routines: HashMap<RoutineLocator, RoutineAccumulator>,
4377) -> Result<(), CatalogError> {
4378    for (locator, accumulator) in routines {
4379        let Some(signature) = accumulator.signature else {
4380            continue;
4381        };
4382        let kind = accumulator
4383            .kind_hint
4384            .or_else(|| {
4385                if signature.return_type.is_some() {
4386                    Some(RoutineKind::Function)
4387                } else {
4388                    Some(RoutineKind::Procedure)
4389                }
4390            })
4391            .unwrap_or(RoutineKind::Procedure);
4392
4393        if let Some(package_name) = locator.package_name {
4394            upsert_packaged_routine(snapshot, locator.owner, package_name, kind, signature)?;
4395        } else {
4396            upsert_top_level_routine(
4397                snapshot,
4398                locator.owner,
4399                locator.routine_name,
4400                kind,
4401                signature,
4402                accumulator.deterministic,
4403                accumulator.pipelined,
4404            )?;
4405        }
4406    }
4407
4408    Ok(())
4409}
4410
4411fn object_type_from_dictionary_value(text: &str) -> Option<ObjectType> {
4412    match text.trim().to_ascii_uppercase().as_str() {
4413        "TABLE" => Some(ObjectType::Table),
4414        "VIEW" => Some(ObjectType::View),
4415        "MATERIALIZED VIEW" => Some(ObjectType::MaterializedView),
4416        "SEQUENCE" => Some(ObjectType::Sequence),
4417        "TYPE" => Some(ObjectType::Type),
4418        "PACKAGE" => Some(ObjectType::Package),
4419        "PROCEDURE" => Some(ObjectType::Procedure),
4420        "FUNCTION" => Some(ObjectType::Function),
4421        "TRIGGER" => Some(ObjectType::Trigger),
4422        "EDITIONING VIEW" => Some(ObjectType::EditioningView),
4423        _ => None,
4424    }
4425}
4426
4427fn object_status_from_dictionary_value(text: &str) -> ObjectStatus {
4428    match text.trim().to_ascii_uppercase().as_str() {
4429        "VALID" => ObjectStatus::Valid,
4430        "ENABLED" => ObjectStatus::Valid,
4431        "INVALID" => ObjectStatus::Invalid,
4432        "UNUSABLE" | "DISABLED" => ObjectStatus::Invalid,
4433        _ => ObjectStatus::NotApplicable,
4434    }
4435}
4436
4437fn routine_kind_from_dictionary_value(text: Option<&str>) -> Option<RoutineKind> {
4438    match text.map(|value| value.trim().to_ascii_uppercase()) {
4439        Some(value) if value.eq("FUNCTION") => Some(RoutineKind::Function),
4440        Some(value) if value.eq("PROCEDURE") => Some(RoutineKind::Procedure),
4441        _ => None,
4442    }
4443}
4444
4445fn constraint_type_from_dictionary_value(
4446    text: &str,
4447    search_condition: Option<&str>,
4448    has_columns: bool,
4449) -> ConstraintType {
4450    match text.trim().to_ascii_uppercase().as_str() {
4451        "P" => ConstraintType::PrimaryKey,
4452        "R" => ConstraintType::ForeignKey,
4453        "U" => ConstraintType::Unique,
4454        "F" => ConstraintType::Ref,
4455        "C" => {
4456            if has_columns
4457                && search_condition
4458                    .map(|condition| {
4459                        condition
4460                            .trim()
4461                            .to_ascii_uppercase()
4462                            .contains("IS NOT NULL")
4463                    })
4464                    .unwrap_or(false)
4465            {
4466                ConstraintType::NotNull
4467            } else {
4468                ConstraintType::Check
4469            }
4470        }
4471        _ => ConstraintType::Other,
4472    }
4473}
4474
4475fn trigger_timing_from_dictionary_value(text: &str) -> TriggerTiming {
4476    let normalized = text.trim().to_ascii_uppercase();
4477    if normalized.contains("INSTEAD OF") {
4478        TriggerTiming::InsteadOf
4479    } else if normalized.contains("BEFORE") {
4480        TriggerTiming::Before
4481    } else if normalized.contains("AFTER") {
4482        TriggerTiming::After
4483    } else {
4484        TriggerTiming::Unknown
4485    }
4486}
4487
4488fn trigger_level_from_dictionary_value(text: &str) -> TriggerLevel {
4489    let normalized = text.trim().to_ascii_uppercase();
4490    if normalized.contains("EACH ROW") {
4491        TriggerLevel::Row
4492    } else if normalized.contains("STATEMENT") {
4493        TriggerLevel::Statement
4494    } else {
4495        TriggerLevel::Unknown
4496    }
4497}
4498
4499fn trigger_events_from_dictionary_value(text: &str) -> Vec<TriggerEvent> {
4500    let normalized = text.trim().to_ascii_uppercase();
4501    let mut events = Vec::<TriggerEvent>::new();
4502
4503    if normalized.contains("INSERT") {
4504        events.push(TriggerEvent::Insert);
4505    }
4506    if normalized.contains("UPDATE") {
4507        events.push(TriggerEvent::Update);
4508    }
4509    if normalized.contains("DELETE") {
4510        events.push(TriggerEvent::Delete);
4511    }
4512    if normalized.contains("LOGON") {
4513        events.push(TriggerEvent::Logon);
4514    }
4515    if normalized.contains("LOGOFF") {
4516        events.push(TriggerEvent::Logoff);
4517    }
4518    if normalized.contains("DDL") {
4519        events.push(TriggerEvent::Ddl);
4520    }
4521
4522    if events.is_empty() {
4523        events.push(TriggerEvent::Other);
4524    }
4525
4526    events
4527}
4528
4529fn push_unique_column(columns: &mut Vec<ColumnName>, column_name: ColumnName) {
4530    if !columns.contains(&column_name) {
4531        columns.push(column_name);
4532    }
4533}
4534
4535fn routine_locator_from_procedure_row(
4536    snapshot: &mut CatalogSnapshot,
4537    row: &OracleRow,
4538) -> Result<RoutineLocator, CatalogError> {
4539    let owner_text = row.require_text("OWNER")?;
4540    let container_name_text = row.require_text("OBJECT_NAME")?;
4541    let routine_name_text = row
4542        .text("PROCEDURE_NAME")
4543        .unwrap_or(container_name_text)
4544        .trim();
4545
4546    let Some(owner) = snapshot.intern_schema_name(owner_text) else {
4547        return Err(CatalogError::InvalidColumnValue {
4548            column: String::from("OWNER"),
4549            expected: "interned schema name",
4550            value: String::from(owner_text),
4551        });
4552    };
4553    let Some(container_name) = snapshot.intern_object_name(container_name_text) else {
4554        return Err(CatalogError::InvalidColumnValue {
4555            column: String::from("OBJECT_NAME"),
4556            expected: "interned object name",
4557            value: String::from(container_name_text),
4558        });
4559    };
4560    let Some(routine_name) = snapshot.intern_object_name(routine_name_text) else {
4561        return Err(CatalogError::InvalidColumnValue {
4562            column: String::from("PROCEDURE_NAME"),
4563            expected: "interned object name",
4564            value: String::from(routine_name_text),
4565        });
4566    };
4567
4568    Ok(RoutineLocator {
4569        owner,
4570        package_name: if optional_nonblank_text(row, "PROCEDURE_NAME").is_some() {
4571            Some(container_name)
4572        } else {
4573            None
4574        },
4575        routine_name,
4576        subprogram_id: optional_u32(row, "SUBPROGRAM_ID")?,
4577        overload: optional_u32(row, "OVERLOAD")?,
4578    })
4579}
4580
4581fn routine_locator_from_argument_row(
4582    snapshot: &mut CatalogSnapshot,
4583    row: &OracleRow,
4584) -> Result<RoutineLocator, CatalogError> {
4585    let owner_text = row.require_text("OWNER")?;
4586    let routine_name_text = row.require_text("OBJECT_NAME")?;
4587
4588    let Some(owner) = snapshot.intern_schema_name(owner_text) else {
4589        return Err(CatalogError::InvalidColumnValue {
4590            column: String::from("OWNER"),
4591            expected: "interned schema name",
4592            value: String::from(owner_text),
4593        });
4594    };
4595    let package_name = optional_nonblank_text(row, "PACKAGE_NAME")
4596        .map(|value| {
4597            snapshot
4598                .intern_object_name(value)
4599                .ok_or(CatalogError::InvalidColumnValue {
4600                    column: String::from("PACKAGE_NAME"),
4601                    expected: "interned object name",
4602                    value: String::from(value),
4603                })
4604        })
4605        .transpose()?;
4606    let Some(routine_name) = snapshot.intern_object_name(routine_name_text) else {
4607        return Err(CatalogError::InvalidColumnValue {
4608            column: String::from("OBJECT_NAME"),
4609            expected: "interned object name",
4610            value: String::from(routine_name_text),
4611        });
4612    };
4613
4614    Ok(RoutineLocator {
4615        owner,
4616        package_name,
4617        routine_name,
4618        subprogram_id: optional_u32(row, "SUBPROGRAM_ID")?,
4619        overload: optional_u32(row, "OVERLOAD")?,
4620    })
4621}
4622
4623fn upsert_packaged_routine(
4624    snapshot: &mut CatalogSnapshot,
4625    owner: SchemaName,
4626    package_name: ObjectName,
4627    kind: RoutineKind,
4628    signature: RoutineSignature,
4629) -> Result<(), CatalogError> {
4630    let schema_catalog = snapshot.schemas.entry(owner).or_default();
4631
4632    schema_catalog
4633        .objects
4634        .entry(package_name)
4635        .or_insert_with(|| {
4636            CatalogObject::Package(PackageMetadata {
4637                common: ObjectCommon {
4638                    owner,
4639                    name: package_name,
4640                    object_type: ObjectType::Package,
4641                    ..ObjectCommon::default()
4642                },
4643                ..PackageMetadata::default()
4644            })
4645        });
4646
4647    let Some(CatalogObject::Package(metadata)) = schema_catalog.objects.get_mut(&package_name)
4648    else {
4649        return Ok(());
4650    };
4651
4652    match kind {
4653        RoutineKind::Procedure => upsert_signature(&mut metadata.procedures, signature),
4654        RoutineKind::Function => upsert_signature(&mut metadata.functions, signature),
4655    }
4656
4657    Ok(())
4658}
4659
4660fn upsert_top_level_routine(
4661    snapshot: &mut CatalogSnapshot,
4662    owner: SchemaName,
4663    routine_name: ObjectName,
4664    kind: RoutineKind,
4665    signature: RoutineSignature,
4666    deterministic: bool,
4667    pipelined: bool,
4668) -> Result<(), CatalogError> {
4669    let schema_catalog = snapshot.schemas.entry(owner).or_default();
4670    let common = schema_catalog
4671        .objects
4672        .get(&routine_name)
4673        .and_then(|object| match object {
4674            CatalogObject::Procedure(metadata) => Some(metadata.common.clone()),
4675            CatalogObject::Function(metadata) => Some(metadata.common.clone()),
4676            _ => None,
4677        })
4678        .unwrap_or_else(|| ObjectCommon {
4679            owner,
4680            name: routine_name,
4681            object_type: match kind {
4682                RoutineKind::Procedure => ObjectType::Procedure,
4683                RoutineKind::Function => ObjectType::Function,
4684            },
4685            ..ObjectCommon::default()
4686        });
4687
4688    let catalog_object = match kind {
4689        RoutineKind::Procedure => CatalogObject::Procedure(ProcedureMetadata { common, signature }),
4690        RoutineKind::Function => CatalogObject::Function(FunctionMetadata {
4691            common,
4692            signature,
4693            deterministic,
4694            pipelined,
4695        }),
4696    };
4697    schema_catalog.objects.insert(routine_name, catalog_object);
4698
4699    Ok(())
4700}
4701
4702fn upsert_signature(signatures: &mut Vec<RoutineSignature>, signature: RoutineSignature) {
4703    if let Some(existing) = signatures.iter_mut().find(|candidate| {
4704        candidate.routine_name.eq(&signature.routine_name)
4705            && candidate.overload.eq(&signature.overload)
4706    }) {
4707        *existing = signature;
4708    } else {
4709        signatures.push(signature);
4710    }
4711}
4712
4713fn data_type_ref_from_argument_row(
4714    snapshot: &mut CatalogSnapshot,
4715    row: &OracleRow,
4716) -> Result<DataTypeRef, CatalogError> {
4717    let owner = optional_nonblank_text(row, "TYPE_OWNER")
4718        .map(|value| {
4719            snapshot
4720                .intern_schema_name(value)
4721                .ok_or(CatalogError::InvalidColumnValue {
4722                    column: String::from("TYPE_OWNER"),
4723                    expected: "interned schema name",
4724                    value: String::from(value),
4725                })
4726        })
4727        .transpose()?;
4728    let type_name = optional_nonblank_text(row, "TYPE_NAME")
4729        .or_else(|| optional_nonblank_text(row, "DATA_TYPE"))
4730        .unwrap_or_default();
4731
4732    Ok(DataTypeRef {
4733        owner,
4734        name: String::from(type_name),
4735        length: optional_u32(row, "DATA_LENGTH")?,
4736        precision: optional_u32(row, "DATA_PRECISION")?,
4737        scale: optional_i32(row, "DATA_SCALE")?,
4738        char_semantics: None,
4739    })
4740}
4741
4742fn parameter_mode_from_dictionary_value(text: Option<&str>) -> ParameterMode {
4743    match text.map(|value| value.trim().to_ascii_uppercase()) {
4744        Some(value) if value.eq("OUT") => ParameterMode::Out,
4745        Some(value) if value.eq("IN/OUT") => ParameterMode::InOut,
4746        _ => ParameterMode::In,
4747    }
4748}
4749
4750fn blank_catalog_object(common: ObjectCommon) -> Option<CatalogObject> {
4751    match common.object_type {
4752        ObjectType::Table => Some(CatalogObject::Table(TableMetadata {
4753            common,
4754            ..TableMetadata::default()
4755        })),
4756        ObjectType::View => Some(CatalogObject::View(ViewMetadata {
4757            common,
4758            ..ViewMetadata::default()
4759        })),
4760        ObjectType::MaterializedView => Some(CatalogObject::MaterializedView(MViewMetadata {
4761            common,
4762            ..MViewMetadata::default()
4763        })),
4764        ObjectType::Sequence => Some(CatalogObject::Sequence(SequenceMetadata {
4765            common,
4766            ..SequenceMetadata::default()
4767        })),
4768        ObjectType::Type => Some(CatalogObject::Type(TypeMetadata {
4769            common,
4770            ..TypeMetadata::default()
4771        })),
4772        ObjectType::Package => Some(CatalogObject::Package(PackageMetadata {
4773            common,
4774            ..PackageMetadata::default()
4775        })),
4776        ObjectType::Procedure => Some(CatalogObject::Procedure(ProcedureMetadata {
4777            common,
4778            ..ProcedureMetadata::default()
4779        })),
4780        ObjectType::Function => Some(CatalogObject::Function(FunctionMetadata {
4781            common,
4782            ..FunctionMetadata::default()
4783        })),
4784        ObjectType::Trigger => Some(CatalogObject::Trigger(TriggerMetadata {
4785            common,
4786            ..TriggerMetadata::default()
4787        })),
4788        ObjectType::SchedulerJob => Some(CatalogObject::SchedulerJob(SchedulerJobMetadata {
4789            common,
4790            ..SchedulerJobMetadata::default()
4791        })),
4792        ObjectType::EditioningView => Some(CatalogObject::EditioningView(EditioningViewMetadata {
4793            common,
4794            ..EditioningViewMetadata::default()
4795        })),
4796        ObjectType::Synonym | ObjectType::Index | ObjectType::Constraint | ObjectType::Unknown => {
4797            None
4798        }
4799    }
4800}
4801
4802fn data_type_ref_from_row(
4803    snapshot: &mut CatalogSnapshot,
4804    row: &OracleRow,
4805) -> Result<DataTypeRef, CatalogError> {
4806    let owner = row
4807        .text("DATA_TYPE_OWNER")
4808        .map(str::trim)
4809        .filter(|value| !value.is_empty())
4810        .map(|value| {
4811            snapshot
4812                .intern_schema_name(value)
4813                .ok_or(CatalogError::InvalidColumnValue {
4814                    column: String::from("DATA_TYPE_OWNER"),
4815                    expected: "interned schema name",
4816                    value: String::from(value),
4817                })
4818        })
4819        .transpose()?;
4820
4821    Ok(DataTypeRef {
4822        owner,
4823        name: String::from(row.require_text("DATA_TYPE")?),
4824        length: optional_u32(row, "DATA_LENGTH")?,
4825        precision: optional_u32(row, "DATA_PRECISION")?,
4826        scale: optional_i32(row, "DATA_SCALE")?,
4827        char_semantics: row.text("CHAR_USED").map(String::from),
4828    })
4829}
4830
4831fn optional_bool(row: &OracleRow, column: &str) -> Result<Option<bool>, CatalogError> {
4832    match row.text(column) {
4833        Some(_) => row.parse_bool(column).map(Some),
4834        None => Ok(None),
4835    }
4836}
4837
4838fn optional_nonblank_text<'a>(row: &'a OracleRow, column: &str) -> Option<&'a str> {
4839    row.text(column)
4840        .map(str::trim)
4841        .filter(|value| !value.is_empty())
4842}
4843
4844fn optional_u32(row: &OracleRow, column: &str) -> Result<Option<u32>, CatalogError> {
4845    match row.text(column) {
4846        Some(_) => {
4847            let parsed = row.parse_u64(column)?;
4848            u32::try_from(parsed)
4849                .map(Some)
4850                .map_err(|_| CatalogError::InvalidColumnValue {
4851                    column: column.to_ascii_uppercase(),
4852                    expected: "u32",
4853                    value: parsed.to_string(),
4854                })
4855        }
4856        None => Ok(None),
4857    }
4858}
4859
4860fn required_u32(row: &OracleRow, column: &str) -> Result<u32, CatalogError> {
4861    let parsed = row.parse_u64(column)?;
4862    u32::try_from(parsed).map_err(|_| CatalogError::InvalidColumnValue {
4863        column: column.to_ascii_uppercase(),
4864        expected: "u32",
4865        value: parsed.to_string(),
4866    })
4867}
4868
4869fn optional_i32(row: &OracleRow, column: &str) -> Result<Option<i32>, CatalogError> {
4870    match row.text(column) {
4871        Some(_) => {
4872            let parsed = row.parse_i64(column)?;
4873            i32::try_from(parsed)
4874                .map(Some)
4875                .map_err(|_| CatalogError::InvalidColumnValue {
4876                    column: column.to_ascii_uppercase(),
4877                    expected: "i32",
4878                    value: parsed.to_string(),
4879                })
4880        }
4881        None => Ok(None),
4882    }
4883}
4884
4885#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
4886pub struct SynonymTarget {
4887    pub target_owner: Option<SchemaName>,
4888    pub target_name: ObjectName,
4889    pub target_type: Option<ObjectType>,
4890    pub db_link: Option<String>,
4891    pub public_synonym: bool,
4892}
4893
4894/// Virtual Private Database (VPD / RLS) policy entry from
4895/// `ALL_POLICIES`. Each row describes one policy attached to an object;
4896/// the policy function (PF_OWNER.PACKAGE.FUNCTION) is the predicate
4897/// generator that Oracle invokes at parse time to inject a WHERE clause
4898/// into reads (and optional ones into INSERT/UPDATE/DELETE).
4899///
4900/// Lineage flags VPD-protected objects with
4901/// `UnknownReason::DbLinkRemoteObject` reused-as-marker pending a
4902/// dedicated `UnknownReason::VpdPolicyApplied` variant.
4903#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
4904pub struct VpdPolicy {
4905    /// Owning schema (the object's owner).
4906    pub object_owner: SchemaName,
4907    /// Protected object.
4908    pub object_name: ObjectName,
4909    /// Optional policy-group name (Oracle policy-groups; usually NULL).
4910    pub policy_group: Option<String>,
4911    /// Policy name within the group.
4912    pub policy_name: String,
4913    /// Owner of the policy function.
4914    pub function_owner: SchemaName,
4915    /// Package containing the policy function (NULL for standalone).
4916    pub function_package: Option<String>,
4917    /// Function name that produces the WHERE-clause predicate.
4918    pub function_name: String,
4919    /// Statement-type bits — true means the policy applies to that DML.
4920    pub on_select: bool,
4921    pub on_insert: bool,
4922    pub on_update: bool,
4923    pub on_delete: bool,
4924    /// Whether the policy is currently enabled.
4925    pub enabled: bool,
4926}
4927
4928/// Edition entry from `ALL_EDITIONS` — the per-database edition tree
4929/// used by Oracle Edition-Based Redefinition (EBR). Linked into
4930/// [`CatalogSnapshot::editions`].
4931#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
4932pub struct Edition {
4933    /// Edition name (Oracle identifiers are case-preserving but case-
4934    /// insensitive when unquoted; stored as the dictionary value).
4935    pub edition_name: String,
4936    /// Parent edition, if any. `None` for the root edition (typically
4937    /// `ORA$BASE`).
4938    pub parent_edition_name: Option<String>,
4939    /// Whether the edition is currently usable (`USABLE = 'Y'`).
4940    pub usable: bool,
4941}
4942
4943/// Editioning view from `ALL_EDITIONING_VIEWS` — a view that masks an
4944/// editioned table during EBR cutovers. Linked into
4945/// [`SchemaCatalog::editioning_views`].
4946#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
4947pub struct EditioningView {
4948    /// Owning schema.
4949    pub owner: SchemaName,
4950    /// Editioning view name.
4951    pub view_name: ObjectName,
4952    /// Base table the editioning view masks.
4953    pub table_name: ObjectName,
4954}
4955
4956/// Documentation comment attached to a table, view, or materialized
4957/// view via `COMMENT ON TABLE owner.name IS '...'`. Sourced from
4958/// `ALL_TAB_COMMENTS`.
4959///
4960/// `plsql-docgen` consumes these to render description text alongside
4961/// object docs; dependency analysis does not interact with them.
4962#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
4963pub struct TableComment {
4964    /// Owning schema.
4965    pub owner: SchemaName,
4966    /// Object name.
4967    pub table_name: ObjectName,
4968    /// `TABLE` / `VIEW` / `MATERIALIZED VIEW` — preserved verbatim
4969    /// from `ALL_TAB_COMMENTS.TABLE_TYPE` so docgen can pick a
4970    /// per-kind template.
4971    pub table_type: String,
4972    /// Comment text. Always present (we filter out NULL rows server-side
4973    /// to keep the snapshot compact).
4974    pub comments: String,
4975}
4976
4977/// Documentation comment attached to a column via
4978/// `COMMENT ON COLUMN owner.table.column IS '...'`. Sourced from
4979/// `ALL_COL_COMMENTS`.
4980#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
4981pub struct ColumnComment {
4982    /// Owning schema.
4983    pub owner: SchemaName,
4984    /// Object name.
4985    pub table_name: ObjectName,
4986    /// Column name.
4987    pub column_name: ColumnName,
4988    /// Comment text.
4989    pub comments: String,
4990}
4991
4992/// Database link metadata sourced from `ALL_DB_LINKS`.
4993///
4994/// PL/SQL code that references `remote_object@my_link` resolves through
4995/// one of these entries. Public links have `owner = PUBLIC`. Lineage uses
4996/// the `host` field to classify the remote endpoint (a TNS alias, a full
4997/// EZCONNECT string, or the legacy `(DESCRIPTION=...)` form).
4998///
4999/// The shape intentionally avoids `username` — `ALL_DB_LINKS.USERNAME`
5000/// is the *connect user*, not a privilege grant, and most consumers
5001/// don't need it. If a future product surface needs the connect user it
5002/// can be added behind `#[serde(default)]` without breaking older
5003/// snapshots.
5004#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5005pub struct DatabaseLink {
5006    /// Owning schema. `PUBLIC` for public links.
5007    pub owner: SchemaName,
5008    /// Link name, e.g. `REPORTING.WORLD`.
5009    pub name: String,
5010    /// Connect-target host string. Can be a TNS alias, an EZCONNECT
5011    /// string, or a full `(DESCRIPTION=...)` block.
5012    pub host: Option<String>,
5013    /// `true` when `owner` is `PUBLIC`. Surfaced for fast filtering in
5014    /// downstream lineage without re-resolving the schema interner.
5015    pub public_link: bool,
5016}
5017
5018#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5019pub enum GrantPrivilege {
5020    Select,
5021    Insert,
5022    Update,
5023    Delete,
5024    Execute,
5025    Alter,
5026    Index,
5027    References,
5028    Debug,
5029    #[default]
5030    Other,
5031}
5032
5033#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5034pub enum Grantee {
5035    User(UserName),
5036    Role(RoleName),
5037    #[default]
5038    Public,
5039}
5040
5041#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5042pub struct Grant {
5043    pub object_owner: SchemaName,
5044    pub object_name: ObjectName,
5045    pub privilege: GrantPrivilege,
5046    pub grantee: Grantee,
5047    pub grantable: bool,
5048    pub via_role: Option<RoleName>,
5049    pub with_hierarchy: bool,
5050}
5051
5052#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5053pub struct IndexMetadata {
5054    pub name: IndexName,
5055    pub table_owner: SchemaName,
5056    pub table_name: ObjectName,
5057    pub unique: bool,
5058    pub columns: Vec<ColumnName>,
5059    pub index_type: String,
5060    pub status: ObjectStatus,
5061}
5062
5063#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5064pub enum ConstraintType {
5065    PrimaryKey,
5066    ForeignKey,
5067    Unique,
5068    Check,
5069    NotNull,
5070    Ref,
5071    #[default]
5072    Other,
5073}
5074
5075#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5076pub struct ConstraintMetadata {
5077    pub name: ConstraintName,
5078    pub table_owner: SchemaName,
5079    pub table_name: ObjectName,
5080    pub constraint_type: ConstraintType,
5081    pub columns: Vec<ColumnName>,
5082    pub referenced_table_owner: Option<SchemaName>,
5083    pub referenced_table_name: Option<ObjectName>,
5084    pub referenced_columns: Vec<ColumnName>,
5085    pub search_condition: Option<String>,
5086    pub deferrable: Option<bool>,
5087    pub initially_deferred: Option<bool>,
5088}
5089
5090#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5091pub enum CatalogDependencyKind {
5092    Hard,
5093    Reference,
5094    Extended,
5095    #[default]
5096    Other,
5097}
5098
5099#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5100pub struct CatalogDependency {
5101    pub owner: SchemaName,
5102    pub name: ObjectName,
5103    pub object_type: ObjectType,
5104    pub referenced_owner: Option<SchemaName>,
5105    pub referenced_name: ObjectName,
5106    pub referenced_type: Option<ObjectType>,
5107    pub dependency_kind: CatalogDependencyKind,
5108    pub via_db_link: Option<String>,
5109}
5110
5111#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5112pub enum PlScopeAvailability {
5113    #[default]
5114    NotAvailable,
5115    AvailableButStale,
5116    IdentifiersOnly,
5117    IdentifiersAndStatements,
5118}
5119
5120#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5121pub struct CompilerIdentifier {
5122    pub owner: SchemaName,
5123    pub object_name: ObjectName,
5124    pub identifier_name: MemberName,
5125    pub identifier_type: String,
5126    pub usage: String,
5127    pub line: u32,
5128    pub column: u32,
5129}
5130
5131#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5132pub struct CompilerReference {
5133    pub owner: SchemaName,
5134    pub object_name: ObjectName,
5135    pub usage_line: u32,
5136    pub usage_column: u32,
5137    pub target_owner: Option<SchemaName>,
5138    pub target_object_name: Option<ObjectName>,
5139    pub target_identifier_name: Option<MemberName>,
5140}
5141
5142#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5143pub struct CompilerStatementUsage {
5144    pub owner: SchemaName,
5145    pub object_name: ObjectName,
5146    pub statement_kind: String,
5147    pub line: u32,
5148    pub column: u32,
5149}
5150
5151#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5152pub struct PlScopeSnapshot {
5153    pub availability: PlScopeAvailability,
5154    pub identifiers: Vec<CompilerIdentifier>,
5155    pub references: Vec<CompilerReference>,
5156    pub statements: Vec<CompilerStatementUsage>,
5157    pub collected_at: Option<DateTime<Utc>>,
5158    pub source_hash: Option<Hash>,
5159    pub warnings: Vec<CapabilityWarning>,
5160}
5161
5162#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5163pub struct DataTypeRef {
5164    pub owner: Option<SchemaName>,
5165    pub name: String,
5166    pub length: Option<u32>,
5167    pub precision: Option<u32>,
5168    pub scale: Option<i32>,
5169    pub char_semantics: Option<String>,
5170}
5171
5172#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5173pub struct ColumnMetadata {
5174    pub name: ColumnName,
5175    pub position: u32,
5176    pub data_type: DataTypeRef,
5177    pub nullable: bool,
5178    pub default_expression: Option<String>,
5179    pub generated_expression: Option<String>,
5180    pub hidden: bool,
5181}
5182
5183#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5184pub enum TemporaryTableDuration {
5185    #[default]
5186    Transaction,
5187    Session,
5188}
5189
5190#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5191pub struct TableMetadata {
5192    pub common: ObjectCommon,
5193    pub columns: HashMap<ColumnName, ColumnMetadata>,
5194    pub temporary: bool,
5195    pub temporary_duration: Option<TemporaryTableDuration>,
5196    pub index_organized: bool,
5197}
5198
5199#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5200pub struct ViewMetadata {
5201    pub common: ObjectCommon,
5202    pub columns: HashMap<ColumnName, ColumnMetadata>,
5203    pub query_hash: Option<Hash>,
5204    pub read_only: Option<bool>,
5205    pub check_option: Option<String>,
5206}
5207
5208#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5209pub struct MViewMetadata {
5210    pub common: ObjectCommon,
5211    pub columns: HashMap<ColumnName, ColumnMetadata>,
5212    pub refresh_mode: Option<String>,
5213    pub refresh_method: Option<String>,
5214    pub query_hash: Option<Hash>,
5215}
5216
5217#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5218pub struct SequenceMetadata {
5219    pub common: ObjectCommon,
5220    pub increment_by: i64,
5221    pub min_value: Option<i64>,
5222    pub max_value: Option<i64>,
5223    pub cycle: bool,
5224    pub ordered: bool,
5225    pub cache_size: Option<u64>,
5226}
5227
5228#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5229pub enum ParameterMode {
5230    #[default]
5231    In,
5232    Out,
5233    InOut,
5234}
5235
5236#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5237pub struct ArgumentMetadata {
5238    pub position: u32,
5239    pub name: Option<MemberName>,
5240    pub mode: ParameterMode,
5241    pub data_type: DataTypeRef,
5242    pub defaulted: bool,
5243}
5244
5245#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5246pub struct AccessibleByTarget {
5247    pub owner: Option<SchemaName>,
5248    pub object_name: ObjectName,
5249}
5250
5251#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5252pub struct RoutineSignature {
5253    pub routine_name: ObjectName,
5254    pub overload: Option<u32>,
5255    pub arguments: Vec<ArgumentMetadata>,
5256    pub return_type: Option<DataTypeRef>,
5257    pub authid_current_user: Option<bool>,
5258    pub accessible_by: Vec<AccessibleByTarget>,
5259}
5260
5261#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5262pub enum TypeFinality {
5263    Final,
5264    NotFinal,
5265    #[default]
5266    Unknown,
5267}
5268
5269#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5270pub enum TypeInstantiable {
5271    Instantiable,
5272    NotInstantiable,
5273    #[default]
5274    Unknown,
5275}
5276
5277#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5278pub struct TypeAttribute {
5279    pub name: MemberName,
5280    pub position: u32,
5281    pub data_type: DataTypeRef,
5282}
5283
5284#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5285pub struct TypeMetadata {
5286    pub common: ObjectCommon,
5287    pub attributes: Vec<TypeAttribute>,
5288    pub methods: Vec<RoutineSignature>,
5289    pub supertype_owner: Option<SchemaName>,
5290    pub supertype_name: Option<ObjectName>,
5291    pub finality: TypeFinality,
5292    pub instantiable: TypeInstantiable,
5293}
5294
5295#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5296pub struct PackageMetadata {
5297    pub common: ObjectCommon,
5298    pub procedures: Vec<RoutineSignature>,
5299    pub functions: Vec<RoutineSignature>,
5300    pub package_stateful: Option<bool>,
5301    pub authid_current_user: Option<bool>,
5302    pub accessible_by: Vec<AccessibleByTarget>,
5303}
5304
5305#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5306pub struct ProcedureMetadata {
5307    pub common: ObjectCommon,
5308    pub signature: RoutineSignature,
5309}
5310
5311#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5312pub struct FunctionMetadata {
5313    pub common: ObjectCommon,
5314    pub signature: RoutineSignature,
5315    pub deterministic: bool,
5316    pub pipelined: bool,
5317}
5318
5319#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5320pub enum TriggerTiming {
5321    Before,
5322    After,
5323    InsteadOf,
5324    #[default]
5325    Unknown,
5326}
5327
5328#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5329pub enum TriggerLevel {
5330    Statement,
5331    Row,
5332    #[default]
5333    Unknown,
5334}
5335
5336#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5337pub enum TriggerEvent {
5338    Insert,
5339    Update,
5340    Delete,
5341    Logon,
5342    Logoff,
5343    Ddl,
5344    #[default]
5345    Other,
5346}
5347
5348#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5349pub struct TriggerMetadata {
5350    pub common: ObjectCommon,
5351    pub target_owner: SchemaName,
5352    pub target_name: ObjectName,
5353    pub timing: TriggerTiming,
5354    pub level: TriggerLevel,
5355    pub events: Vec<TriggerEvent>,
5356    pub when_clause: Option<String>,
5357    pub body_hash: Option<Hash>,
5358}
5359
5360#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5361pub struct SchedulerJobMetadata {
5362    pub common: ObjectCommon,
5363    pub enabled: bool,
5364    pub job_type: String,
5365    pub program_name: Option<ObjectName>,
5366    pub schedule_name: Option<ObjectName>,
5367    pub job_action: Option<String>,
5368}
5369
5370#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
5371pub struct EditioningViewMetadata {
5372    pub common: ObjectCommon,
5373    pub base_table_owner: SchemaName,
5374    pub base_table_name: ObjectName,
5375    pub columns: HashMap<ColumnName, ColumnMetadata>,
5376}
5377
5378#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
5379pub enum CatalogObject {
5380    Table(TableMetadata),
5381    View(ViewMetadata),
5382    MaterializedView(MViewMetadata),
5383    Sequence(SequenceMetadata),
5384    Type(TypeMetadata),
5385    Package(PackageMetadata),
5386    Procedure(ProcedureMetadata),
5387    Function(FunctionMetadata),
5388    Trigger(TriggerMetadata),
5389    SchedulerJob(SchedulerJobMetadata),
5390    EditioningView(EditioningViewMetadata),
5391}
5392
5393#[cfg(test)]
5394mod tests {
5395    use std::collections::{HashMap, HashSet};
5396    use std::future::Future;
5397
5398    use asupersync::{Cx, runtime::RuntimeBuilder};
5399    use chrono::{DateTime, Utc};
5400    use plsql_core::{AnalysisProfile, ColumnName, MemberName, ObjectName, SchemaName, SymbolId};
5401    use tempfile::tempdir;
5402
5403    use crate::{
5404        AccessibleByTarget, CATALOG_SNAPSHOT_SCHEMA_ID, CATALOG_SNAPSHOT_SCHEMA_VERSION,
5405        CapabilityWarning, CatalogCapabilities, CatalogDependencyKind, CatalogError,
5406        CatalogLoadRequest, CatalogObject, CatalogSchemaFilter, CatalogSnapshot,
5407        CatalogSnapshotDocument, CatalogSource, CatalogSourceKind, CompilerIdentifier,
5408        ConstraintType, DataTypeRef, Hash, ObjectCommon, ObjectStatus, ObjectType, OracleBackend,
5409        OracleBind, OracleConnectOptions, OracleConnection, OracleConnectionInfo, OracleRow,
5410        PackageMetadata, PlScopeAvailability, PlScopeSnapshot, RoutineSignature, SchemaCatalog,
5411        SynonymName, SynonymTarget, TableMetadata, TriggerEvent, TriggerLevel, TriggerName,
5412        TriggerTiming, TypeFinality, TypeInstantiable, export_snapshot_to_json,
5413        grantee_from_dictionary_value, load_catalog_users, load_from_dbms_metadata_dir,
5414        load_snapshot_from_connection, load_snapshot_from_json, negotiate_capabilities,
5415        populate_dbms_metadata_ddl,
5416    };
5417
5418    #[derive(Clone, Debug, Eq, PartialEq)]
5419    struct QueryExpectation {
5420        sql_contains: String,
5421        params: Vec<OracleBind>,
5422        rows: Vec<OracleRow>,
5423    }
5424
5425    #[derive(Clone, Debug)]
5426    struct StaticConnection {
5427        rows: Vec<OracleRow>,
5428        row_count: u64,
5429        expected_queries: Vec<QueryExpectation>,
5430        connection_info: OracleConnectionInfo,
5431    }
5432
5433    impl Default for StaticConnection {
5434        fn default() -> Self {
5435            Self {
5436                rows: Vec::new(),
5437                row_count: 0,
5438                expected_queries: Vec::new(),
5439                connection_info: OracleConnectionInfo {
5440                    backend: OracleBackend::RustOracle,
5441                    connect_string: String::from("//localhost/XE"),
5442                    current_schema: Some(String::from("BILLING")),
5443                    server_version: String::from("23.0.0.0.0"),
5444                    db_name: String::from("XE"),
5445                    db_domain: String::new(),
5446                    service_name: String::from("XE"),
5447                    instance_name: String::from("xe"),
5448                    server_type: String::from("Dedicated"),
5449                    max_identifier_length: 128,
5450                    max_open_cursors: 500,
5451                },
5452            }
5453        }
5454    }
5455
5456    #[async_trait::async_trait(?Send)]
5457    impl OracleConnection for StaticConnection {
5458        fn backend(&self) -> OracleBackend {
5459            OracleBackend::RustOracle
5460        }
5461
5462        async fn ping(&self, cx: &Cx) -> Result<(), CatalogError> {
5463            let _ = cx;
5464            Ok(())
5465        }
5466
5467        async fn describe(&self, cx: &Cx) -> Result<OracleConnectionInfo, CatalogError> {
5468            let _ = cx;
5469            Ok(self.connection_info.clone())
5470        }
5471
5472        async fn query_rows(
5473            &self,
5474            cx: &Cx,
5475            sql: &str,
5476            params: &[OracleBind],
5477        ) -> Result<Vec<OracleRow>, CatalogError> {
5478            let _ = cx;
5479            if !self.expected_queries.is_empty() {
5480                if let Some(expectation) = self.expected_queries.iter().find(|expectation| {
5481                    sql.contains(expectation.sql_contains.as_str())
5482                        && params.eq(expectation.params.as_slice())
5483                }) {
5484                    return Ok(expectation.rows.clone());
5485                }
5486
5487                return Err(CatalogError::OracleBackendError {
5488                    backend: OracleBackend::RustOracle,
5489                    message: format!("unexpected query `{sql}` with params {params:?}"),
5490                });
5491            }
5492
5493            Ok(self.rows.clone())
5494        }
5495
5496        async fn execute(
5497            &self,
5498            cx: &Cx,
5499            _sql: &str,
5500            _params: &[OracleBind],
5501        ) -> Result<u64, CatalogError> {
5502            let _ = cx;
5503            Ok(self.row_count)
5504        }
5505    }
5506
5507    fn run_catalog_future<F: Future>(future: F) -> F::Output {
5508        RuntimeBuilder::current_thread()
5509            .build()
5510            .expect("test asupersync runtime")
5511            .block_on(future)
5512    }
5513
5514    fn test_cx() -> Cx {
5515        Cx::current().expect("test runtime installs a request Cx")
5516    }
5517
5518    fn load_snapshot_for_test<C: OracleConnection>(
5519        connection: &C,
5520        request: &CatalogLoadRequest,
5521    ) -> Result<CatalogSnapshot, CatalogError> {
5522        run_catalog_future(async {
5523            let cx = test_cx();
5524            load_snapshot_from_connection(&cx, connection, request).await
5525        })
5526    }
5527
5528    fn load_catalog_users_for_test<C: OracleConnection>(
5529        connection: &C,
5530        snapshot: &mut CatalogSnapshot,
5531    ) -> Result<(), CatalogError> {
5532        run_catalog_future(async {
5533            let cx = test_cx();
5534            load_catalog_users(&cx, connection, snapshot).await
5535        })
5536    }
5537
5538    fn populate_dbms_metadata_ddl_for_test<C: OracleConnection>(
5539        connection: &C,
5540        snapshot: &mut CatalogSnapshot,
5541    ) -> Result<(), CatalogError> {
5542        run_catalog_future(async {
5543            let cx = test_cx();
5544            populate_dbms_metadata_ddl(&cx, connection, snapshot).await
5545        })
5546    }
5547
5548    fn negotiate_capabilities_for_test<C: OracleConnection>(connection: &C) -> CatalogCapabilities {
5549        run_catalog_future(async {
5550            let cx = test_cx();
5551            negotiate_capabilities(&cx, connection).await
5552        })
5553    }
5554
5555    fn oracle_row(columns: &[(&str, &str, Option<&str>)]) -> OracleRow {
5556        let mut row = OracleRow::default();
5557        for (name, oracle_type, value) in columns {
5558            row.insert(*name, *oracle_type, value.map(String::from));
5559        }
5560        row
5561    }
5562
5563    /// Probe queries issued by `negotiate_capabilities`. Every mock test
5564    /// that wants the live-extraction loader to behave normally should
5565    /// prepend these to its `expected_queries` so each probe succeeds.
5566    fn capability_probe_expectations() -> Vec<QueryExpectation> {
5567        [
5568            "from all_objects where rownum = 0",
5569            "from dba_objects where rownum = 0",
5570            "from all_source where rownum = 0",
5571            "from all_scheduler_jobs where rownum = 0",
5572            "from all_tab_privs where rownum = 0",
5573            "from all_plsql_object_settings where rownum = 0",
5574        ]
5575        .into_iter()
5576        .map(|fragment| QueryExpectation {
5577            sql_contains: String::from(fragment),
5578            params: vec![],
5579            rows: vec![],
5580        })
5581        .collect()
5582    }
5583
5584    /// `ALL_USERS` extraction expectation. The live loader queries this
5585    /// (database-wide, no schema bind) to learn which grantees are users so
5586    /// `grantee_from_dictionary_value` can tell users from roles. Tests pass
5587    /// the set of usernames they want to be classified as `Grantee::User`;
5588    /// any grantee absent from this set is classified as `Grantee::Role`.
5589    fn all_users_expectation(usernames: &[&str]) -> QueryExpectation {
5590        QueryExpectation {
5591            sql_contains: String::from("from all_users"),
5592            params: vec![],
5593            rows: usernames
5594                .iter()
5595                .map(|name| oracle_row(&[("USERNAME", "VARCHAR2(128)", Some(name))]))
5596                .collect(),
5597        }
5598    }
5599
5600    #[test]
5601    fn oracle_row_helpers_are_case_insensitive_and_typed() {
5602        let mut row = OracleRow::default();
5603        row.insert(
5604            "current_schema",
5605            "VARCHAR2(128)",
5606            Some(String::from("billing")),
5607        );
5608        row.insert("object_id", "NUMBER(10)", Some(String::from("42")));
5609        row.insert("editionable", "VARCHAR2(1)", Some(String::from("Y")));
5610        row.insert("ddl_text", "CLOB", None);
5611
5612        assert_eq!(row.text("CURRENT_SCHEMA"), Some("billing"));
5613        assert_eq!(row.parse_u64("object_id").ok(), Some(42));
5614        assert_eq!(row.parse_bool("EDITIONABLE").ok(), Some(true));
5615        assert!(matches!(
5616            row.require_text("ddl_text"),
5617            Err(CatalogError::NullColumnValue { column }) if column.eq("DDL_TEXT")
5618        ));
5619    }
5620
5621    #[test]
5622    fn parse_bool_honors_oracle_conventions_and_errors() {
5623        // The whole catalog's boolean flags (status/editionable/
5624        // deterministic/…) flow through parse_bool. Lock the Oracle
5625        // convention: Y/YES/TRUE/1 -> true, N/NO/FALSE/0 -> false,
5626        // case-insensitive, whitespace-trimmed; anything else is an
5627        // explicit InvalidColumnValue (R13 — never a silent default).
5628        for t in ["Y", "y", "YES", " yes ", "TRUE", "true", "1"] {
5629            let row = oracle_row(&[("FLAG", "VARCHAR2(3)", Some(t))]);
5630            assert_eq!(row.parse_bool("flag").ok(), Some(true), "{t:?} -> true");
5631        }
5632        for f in ["N", "n", "NO", " no ", "FALSE", "false", "0"] {
5633            let row = oracle_row(&[("FLAG", "VARCHAR2(3)", Some(f))]);
5634            assert_eq!(row.parse_bool("flag").ok(), Some(false), "{f:?} -> false");
5635        }
5636        // Unrecognized value -> explicit error, not a silent bool.
5637        let bad = oracle_row(&[("FLAG", "VARCHAR2(5)", Some("MAYBE"))]);
5638        assert!(matches!(
5639            bad.parse_bool("FLAG"),
5640            Err(CatalogError::InvalidColumnValue {
5641                column,
5642                expected: "bool",
5643                ..
5644            }) if column.eq("FLAG")
5645        ));
5646        // Missing column and NULL value are distinct typed errors.
5647        let empty = oracle_row(&[]);
5648        assert!(matches!(
5649            empty.parse_bool("FLAG"),
5650            Err(CatalogError::MissingColumn { column }) if column.eq("FLAG")
5651        ));
5652        let nullv = oracle_row(&[("FLAG", "VARCHAR2(1)", None)]);
5653        assert!(matches!(
5654            nullv.parse_bool("FLAG"),
5655            Err(CatalogError::NullColumnValue { column }) if column.eq("FLAG")
5656        ));
5657
5658        // parse_u64 must reject a negative value (Oracle NUMBER can
5659        // be signed) rather than wrap/panic.
5660        let neg = oracle_row(&[("N", "NUMBER", Some("-1"))]);
5661        assert!(matches!(
5662            neg.parse_u64("N"),
5663            Err(CatalogError::InvalidColumnValue {
5664                expected: "u64",
5665                ..
5666            })
5667        ));
5668        let i = oracle_row(&[("N", "NUMBER", Some("-42"))]);
5669        assert_eq!(i.parse_i64("N").ok(), Some(-42));
5670    }
5671
5672    #[test]
5673    fn catalog_load_request_defaults_to_current_schema() {
5674        let request = CatalogLoadRequest::default();
5675        assert_eq!(
5676            request.schema_filters,
5677            vec![CatalogSchemaFilter::CurrentSchema]
5678        );
5679    }
5680
5681    #[test]
5682    fn load_snapshot_from_connection_extracts_structural_metadata() {
5683        let object_rows = vec![
5684            oracle_row(&[
5685                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
5686                ("OBJECT_NAME", "VARCHAR2(128)", Some("BILLING_API")),
5687                ("OBJECT_TYPE", "VARCHAR2(30)", Some("PACKAGE")),
5688                ("STATUS", "VARCHAR2(7)", Some("VALID")),
5689            ]),
5690            oracle_row(&[
5691                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
5692                ("OBJECT_NAME", "VARCHAR2(128)", Some("CALCULATE_TAX")),
5693                ("OBJECT_TYPE", "VARCHAR2(30)", Some("FUNCTION")),
5694                ("STATUS", "VARCHAR2(7)", Some("VALID")),
5695            ]),
5696            oracle_row(&[
5697                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
5698                ("OBJECT_NAME", "VARCHAR2(128)", Some("CUSTOMERS")),
5699                ("OBJECT_TYPE", "VARCHAR2(30)", Some("TABLE")),
5700                ("STATUS", "VARCHAR2(7)", Some("VALID")),
5701            ]),
5702            oracle_row(&[
5703                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
5704                ("OBJECT_NAME", "VARCHAR2(128)", Some("INVOICES")),
5705                ("OBJECT_TYPE", "VARCHAR2(30)", Some("TABLE")),
5706                ("STATUS", "VARCHAR2(7)", Some("VALID")),
5707            ]),
5708            oracle_row(&[
5709                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
5710                ("OBJECT_NAME", "VARCHAR2(128)", Some("INVOICE_SUMMARY")),
5711                ("OBJECT_TYPE", "VARCHAR2(30)", Some("VIEW")),
5712                ("STATUS", "VARCHAR2(7)", Some("VALID")),
5713            ]),
5714            oracle_row(&[
5715                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
5716                ("OBJECT_NAME", "VARCHAR2(128)", Some("INVOICES_BIU")),
5717                ("OBJECT_TYPE", "VARCHAR2(30)", Some("TRIGGER")),
5718                ("STATUS", "VARCHAR2(7)", Some("VALID")),
5719            ]),
5720        ];
5721        let column_rows = vec![
5722            oracle_row(&[
5723                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
5724                ("TABLE_NAME", "VARCHAR2(128)", Some("CUSTOMERS")),
5725                ("COLUMN_NAME", "VARCHAR2(128)", Some("CUSTOMER_ID")),
5726                ("COLUMN_POSITION", "NUMBER(10)", Some("1")),
5727                ("DATA_TYPE_OWNER", "VARCHAR2(128)", None),
5728                ("DATA_TYPE", "VARCHAR2(128)", Some("NUMBER")),
5729                ("DATA_LENGTH", "NUMBER(10)", Some("22")),
5730                ("DATA_PRECISION", "NUMBER(10)", Some("10")),
5731                ("DATA_SCALE", "NUMBER(10)", Some("0")),
5732                ("CHAR_USED", "VARCHAR2(1)", None),
5733                ("NULLABLE", "VARCHAR2(1)", Some("N")),
5734                ("DATA_DEFAULT_VC", "VARCHAR2(4000)", None),
5735                ("VIRTUAL_COLUMN", "VARCHAR2(3)", Some("NO")),
5736                ("HIDDEN_COLUMN", "VARCHAR2(3)", Some("NO")),
5737            ]),
5738            oracle_row(&[
5739                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
5740                ("TABLE_NAME", "VARCHAR2(128)", Some("INVOICES")),
5741                ("COLUMN_NAME", "VARCHAR2(128)", Some("INVOICE_ID")),
5742                ("COLUMN_POSITION", "NUMBER(10)", Some("1")),
5743                ("DATA_TYPE_OWNER", "VARCHAR2(128)", None),
5744                ("DATA_TYPE", "VARCHAR2(128)", Some("NUMBER")),
5745                ("DATA_LENGTH", "NUMBER(10)", Some("22")),
5746                ("DATA_PRECISION", "NUMBER(10)", Some("10")),
5747                ("DATA_SCALE", "NUMBER(10)", Some("0")),
5748                ("CHAR_USED", "VARCHAR2(1)", None),
5749                ("NULLABLE", "VARCHAR2(1)", Some("N")),
5750                ("DATA_DEFAULT_VC", "VARCHAR2(4000)", None),
5751                ("VIRTUAL_COLUMN", "VARCHAR2(3)", Some("NO")),
5752                ("HIDDEN_COLUMN", "VARCHAR2(3)", Some("NO")),
5753            ]),
5754            oracle_row(&[
5755                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
5756                ("TABLE_NAME", "VARCHAR2(128)", Some("INVOICES")),
5757                ("COLUMN_NAME", "VARCHAR2(128)", Some("CUSTOMER_ID")),
5758                ("COLUMN_POSITION", "NUMBER(10)", Some("2")),
5759                ("DATA_TYPE_OWNER", "VARCHAR2(128)", None),
5760                ("DATA_TYPE", "VARCHAR2(128)", Some("NUMBER")),
5761                ("DATA_LENGTH", "NUMBER(10)", Some("22")),
5762                ("DATA_PRECISION", "NUMBER(10)", Some("10")),
5763                ("DATA_SCALE", "NUMBER(10)", Some("0")),
5764                ("CHAR_USED", "VARCHAR2(1)", None),
5765                ("NULLABLE", "VARCHAR2(1)", Some("N")),
5766                ("DATA_DEFAULT_VC", "VARCHAR2(4000)", None),
5767                ("VIRTUAL_COLUMN", "VARCHAR2(3)", Some("NO")),
5768                ("HIDDEN_COLUMN", "VARCHAR2(3)", Some("NO")),
5769            ]),
5770            oracle_row(&[
5771                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
5772                ("TABLE_NAME", "VARCHAR2(128)", Some("INVOICE_SUMMARY")),
5773                ("COLUMN_NAME", "VARCHAR2(128)", Some("TOTAL_DUE")),
5774                ("COLUMN_POSITION", "NUMBER(10)", Some("1")),
5775                ("DATA_TYPE_OWNER", "VARCHAR2(128)", None),
5776                ("DATA_TYPE", "VARCHAR2(128)", Some("NUMBER")),
5777                ("DATA_LENGTH", "NUMBER(10)", Some("22")),
5778                ("DATA_PRECISION", "NUMBER(10)", Some("12")),
5779                ("DATA_SCALE", "NUMBER(10)", Some("2")),
5780                ("CHAR_USED", "VARCHAR2(1)", None),
5781                ("NULLABLE", "VARCHAR2(1)", Some("Y")),
5782                ("DATA_DEFAULT_VC", "VARCHAR2(4000)", Some("0")),
5783                ("VIRTUAL_COLUMN", "VARCHAR2(3)", Some("YES")),
5784                ("HIDDEN_COLUMN", "VARCHAR2(3)", Some("NO")),
5785            ]),
5786        ];
5787        let constraint_rows = vec![
5788            oracle_row(&[
5789                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
5790                (
5791                    "CONSTRAINT_NAME",
5792                    "VARCHAR2(128)",
5793                    Some("INVOICES_CUSTOMER_FK"),
5794                ),
5795                ("TABLE_NAME", "VARCHAR2(128)", Some("INVOICES")),
5796                ("CONSTRAINT_TYPE", "VARCHAR2(1)", Some("R")),
5797                ("REFERENCED_TABLE_OWNER", "VARCHAR2(128)", Some("BILLING")),
5798                ("REFERENCED_TABLE_NAME", "VARCHAR2(128)", Some("CUSTOMERS")),
5799                ("SEARCH_CONDITION_VC", "VARCHAR2(4000)", None),
5800                ("IS_DEFERRABLE", "VARCHAR2(1)", Some("N")),
5801                ("IS_DEFERRED", "VARCHAR2(1)", Some("N")),
5802                ("COLUMN_NAME", "VARCHAR2(128)", Some("CUSTOMER_ID")),
5803                ("COLUMN_POSITION", "NUMBER(10)", Some("1")),
5804                (
5805                    "REFERENCED_COLUMN_NAME",
5806                    "VARCHAR2(128)",
5807                    Some("CUSTOMER_ID"),
5808                ),
5809            ]),
5810            oracle_row(&[
5811                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
5812                ("CONSTRAINT_NAME", "VARCHAR2(128)", Some("INVOICES_PK")),
5813                ("TABLE_NAME", "VARCHAR2(128)", Some("INVOICES")),
5814                ("CONSTRAINT_TYPE", "VARCHAR2(1)", Some("P")),
5815                ("REFERENCED_TABLE_OWNER", "VARCHAR2(128)", None),
5816                ("REFERENCED_TABLE_NAME", "VARCHAR2(128)", None),
5817                ("SEARCH_CONDITION_VC", "VARCHAR2(4000)", None),
5818                ("IS_DEFERRABLE", "VARCHAR2(1)", Some("N")),
5819                ("IS_DEFERRED", "VARCHAR2(1)", Some("N")),
5820                ("COLUMN_NAME", "VARCHAR2(128)", Some("INVOICE_ID")),
5821                ("COLUMN_POSITION", "NUMBER(10)", Some("1")),
5822                ("REFERENCED_COLUMN_NAME", "VARCHAR2(128)", None),
5823            ]),
5824            oracle_row(&[
5825                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
5826                (
5827                    "CONSTRAINT_NAME",
5828                    "VARCHAR2(128)",
5829                    Some("INVOICES_CUSTOMER_NN"),
5830                ),
5831                ("TABLE_NAME", "VARCHAR2(128)", Some("INVOICES")),
5832                ("CONSTRAINT_TYPE", "VARCHAR2(1)", Some("C")),
5833                ("REFERENCED_TABLE_OWNER", "VARCHAR2(128)", None),
5834                ("REFERENCED_TABLE_NAME", "VARCHAR2(128)", None),
5835                (
5836                    "SEARCH_CONDITION_VC",
5837                    "VARCHAR2(4000)",
5838                    Some("\"CUSTOMER_ID\" IS NOT NULL"),
5839                ),
5840                ("IS_DEFERRABLE", "VARCHAR2(1)", Some("N")),
5841                ("IS_DEFERRED", "VARCHAR2(1)", Some("N")),
5842                ("COLUMN_NAME", "VARCHAR2(128)", Some("CUSTOMER_ID")),
5843                ("COLUMN_POSITION", "NUMBER(10)", Some("1")),
5844                ("REFERENCED_COLUMN_NAME", "VARCHAR2(128)", None),
5845            ]),
5846        ];
5847        let index_rows = vec![oracle_row(&[
5848            ("OWNER", "VARCHAR2(128)", Some("BILLING")),
5849            ("INDEX_NAME", "VARCHAR2(128)", Some("INVOICES_PK_IDX")),
5850            ("TABLE_OWNER", "VARCHAR2(128)", Some("BILLING")),
5851            ("TABLE_NAME", "VARCHAR2(128)", Some("INVOICES")),
5852            ("IS_UNIQUE", "VARCHAR2(1)", Some("Y")),
5853            ("INDEX_TYPE", "VARCHAR2(27)", Some("NORMAL")),
5854            ("STATUS", "VARCHAR2(8)", Some("VALID")),
5855            ("COLUMN_NAME", "VARCHAR2(128)", Some("INVOICE_ID")),
5856            ("COLUMN_POSITION", "NUMBER(10)", Some("1")),
5857        ])];
5858        let trigger_rows = vec![oracle_row(&[
5859            ("OWNER", "VARCHAR2(128)", Some("BILLING")),
5860            ("TRIGGER_NAME", "VARCHAR2(128)", Some("INVOICES_BIU")),
5861            ("TABLE_OWNER", "VARCHAR2(128)", Some("BILLING")),
5862            ("TABLE_NAME", "VARCHAR2(128)", Some("INVOICES")),
5863            ("TRIGGER_TYPE", "VARCHAR2(80)", Some("BEFORE EACH ROW")),
5864            (
5865                "TRIGGERING_EVENT",
5866                "VARCHAR2(246)",
5867                Some("INSERT OR UPDATE"),
5868            ),
5869            ("WHEN_CLAUSE", "VARCHAR2(4000)", Some(":new.total_due >= 0")),
5870        ])];
5871        let synonym_rows = vec![
5872            oracle_row(&[
5873                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
5874                ("SYNONYM_NAME", "VARCHAR2(128)", Some("LATEST_INVOICE")),
5875                ("TABLE_OWNER", "VARCHAR2(128)", Some("BILLING")),
5876                ("TABLE_NAME", "VARCHAR2(128)", Some("INVOICES")),
5877                ("DB_LINK", "VARCHAR2(128)", None),
5878            ]),
5879            oracle_row(&[
5880                ("OWNER", "VARCHAR2(128)", Some("PUBLIC")),
5881                ("SYNONYM_NAME", "VARCHAR2(128)", Some("INVOICE_PUBLIC")),
5882                ("TABLE_OWNER", "VARCHAR2(128)", Some("BILLING")),
5883                ("TABLE_NAME", "VARCHAR2(128)", Some("INVOICES")),
5884                ("DB_LINK", "VARCHAR2(128)", None),
5885            ]),
5886        ];
5887        let procedure_rows = vec![
5888            oracle_row(&[
5889                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
5890                ("OBJECT_NAME", "VARCHAR2(128)", Some("BILLING_API")),
5891                ("PROCEDURE_NAME", "VARCHAR2(128)", Some("CREATE_INVOICE")),
5892                ("SUBPROGRAM_ID", "NUMBER(10)", Some("1")),
5893                ("OVERLOAD", "VARCHAR2(40)", None),
5894                ("OBJECT_TYPE", "VARCHAR2(13)", Some("PROCEDURE")),
5895                ("DETERMINISTIC", "VARCHAR2(3)", Some("NO")),
5896                ("PIPELINED", "VARCHAR2(3)", Some("NO")),
5897            ]),
5898            oracle_row(&[
5899                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
5900                ("OBJECT_NAME", "VARCHAR2(128)", Some("BILLING_API")),
5901                (
5902                    "PROCEDURE_NAME",
5903                    "VARCHAR2(128)",
5904                    Some("TOTAL_FOR_CUSTOMER"),
5905                ),
5906                ("SUBPROGRAM_ID", "NUMBER(10)", Some("2")),
5907                ("OVERLOAD", "VARCHAR2(40)", Some("1")),
5908                ("OBJECT_TYPE", "VARCHAR2(13)", Some("FUNCTION")),
5909                ("DETERMINISTIC", "VARCHAR2(3)", Some("NO")),
5910                ("PIPELINED", "VARCHAR2(3)", Some("NO")),
5911            ]),
5912            oracle_row(&[
5913                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
5914                ("OBJECT_NAME", "VARCHAR2(128)", Some("CALCULATE_TAX")),
5915                ("PROCEDURE_NAME", "VARCHAR2(128)", None),
5916                ("SUBPROGRAM_ID", "NUMBER(10)", Some("1")),
5917                ("OVERLOAD", "VARCHAR2(40)", None),
5918                ("OBJECT_TYPE", "VARCHAR2(13)", Some("FUNCTION")),
5919                ("DETERMINISTIC", "VARCHAR2(3)", Some("YES")),
5920                ("PIPELINED", "VARCHAR2(3)", Some("NO")),
5921            ]),
5922        ];
5923        let argument_rows = vec![
5924            oracle_row(&[
5925                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
5926                ("PACKAGE_NAME", "VARCHAR2(128)", Some("BILLING_API")),
5927                ("OBJECT_NAME", "VARCHAR2(128)", Some("CREATE_INVOICE")),
5928                ("SUBPROGRAM_ID", "NUMBER(10)", Some("1")),
5929                ("OVERLOAD", "VARCHAR2(40)", None),
5930                ("ARGUMENT_NAME", "VARCHAR2(128)", Some("CUSTOMER_ID")),
5931                ("POSITION", "NUMBER(10)", Some("1")),
5932                ("SEQUENCE", "NUMBER(10)", Some("1")),
5933                ("DATA_TYPE", "VARCHAR2(30)", Some("NUMBER")),
5934                ("TYPE_OWNER", "VARCHAR2(128)", None),
5935                ("TYPE_NAME", "VARCHAR2(128)", None),
5936                ("DATA_LENGTH", "NUMBER(10)", Some("22")),
5937                ("DATA_PRECISION", "NUMBER(10)", Some("10")),
5938                ("DATA_SCALE", "NUMBER(10)", Some("0")),
5939                ("IN_OUT", "VARCHAR2(9)", Some("IN")),
5940                ("DEFAULTED", "VARCHAR2(1)", Some("N")),
5941            ]),
5942            oracle_row(&[
5943                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
5944                ("PACKAGE_NAME", "VARCHAR2(128)", Some("BILLING_API")),
5945                ("OBJECT_NAME", "VARCHAR2(128)", Some("CREATE_INVOICE")),
5946                ("SUBPROGRAM_ID", "NUMBER(10)", Some("1")),
5947                ("OVERLOAD", "VARCHAR2(40)", None),
5948                ("ARGUMENT_NAME", "VARCHAR2(128)", Some("TOTAL_DUE")),
5949                ("POSITION", "NUMBER(10)", Some("2")),
5950                ("SEQUENCE", "NUMBER(10)", Some("2")),
5951                ("DATA_TYPE", "VARCHAR2(30)", Some("NUMBER")),
5952                ("TYPE_OWNER", "VARCHAR2(128)", None),
5953                ("TYPE_NAME", "VARCHAR2(128)", None),
5954                ("DATA_LENGTH", "NUMBER(10)", Some("22")),
5955                ("DATA_PRECISION", "NUMBER(10)", Some("12")),
5956                ("DATA_SCALE", "NUMBER(10)", Some("2")),
5957                ("IN_OUT", "VARCHAR2(9)", Some("IN")),
5958                ("DEFAULTED", "VARCHAR2(1)", Some("N")),
5959            ]),
5960            oracle_row(&[
5961                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
5962                ("PACKAGE_NAME", "VARCHAR2(128)", Some("BILLING_API")),
5963                ("OBJECT_NAME", "VARCHAR2(128)", Some("TOTAL_FOR_CUSTOMER")),
5964                ("SUBPROGRAM_ID", "NUMBER(10)", Some("2")),
5965                ("OVERLOAD", "VARCHAR2(40)", Some("1")),
5966                ("ARGUMENT_NAME", "VARCHAR2(128)", None),
5967                ("POSITION", "NUMBER(10)", Some("0")),
5968                ("SEQUENCE", "NUMBER(10)", Some("1")),
5969                ("DATA_TYPE", "VARCHAR2(30)", Some("NUMBER")),
5970                ("TYPE_OWNER", "VARCHAR2(128)", None),
5971                ("TYPE_NAME", "VARCHAR2(128)", None),
5972                ("DATA_LENGTH", "NUMBER(10)", Some("22")),
5973                ("DATA_PRECISION", "NUMBER(10)", Some("12")),
5974                ("DATA_SCALE", "NUMBER(10)", Some("2")),
5975                ("IN_OUT", "VARCHAR2(9)", Some("OUT")),
5976                ("DEFAULTED", "VARCHAR2(1)", Some("N")),
5977            ]),
5978            oracle_row(&[
5979                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
5980                ("PACKAGE_NAME", "VARCHAR2(128)", Some("BILLING_API")),
5981                ("OBJECT_NAME", "VARCHAR2(128)", Some("TOTAL_FOR_CUSTOMER")),
5982                ("SUBPROGRAM_ID", "NUMBER(10)", Some("2")),
5983                ("OVERLOAD", "VARCHAR2(40)", Some("1")),
5984                ("ARGUMENT_NAME", "VARCHAR2(128)", Some("CUSTOMER_ID")),
5985                ("POSITION", "NUMBER(10)", Some("1")),
5986                ("SEQUENCE", "NUMBER(10)", Some("2")),
5987                ("DATA_TYPE", "VARCHAR2(30)", Some("NUMBER")),
5988                ("TYPE_OWNER", "VARCHAR2(128)", None),
5989                ("TYPE_NAME", "VARCHAR2(128)", None),
5990                ("DATA_LENGTH", "NUMBER(10)", Some("22")),
5991                ("DATA_PRECISION", "NUMBER(10)", Some("10")),
5992                ("DATA_SCALE", "NUMBER(10)", Some("0")),
5993                ("IN_OUT", "VARCHAR2(9)", Some("IN")),
5994                ("DEFAULTED", "VARCHAR2(1)", Some("N")),
5995            ]),
5996            oracle_row(&[
5997                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
5998                ("PACKAGE_NAME", "VARCHAR2(128)", None),
5999                ("OBJECT_NAME", "VARCHAR2(128)", Some("CALCULATE_TAX")),
6000                ("SUBPROGRAM_ID", "NUMBER(10)", Some("1")),
6001                ("OVERLOAD", "VARCHAR2(40)", None),
6002                ("ARGUMENT_NAME", "VARCHAR2(128)", None),
6003                ("POSITION", "NUMBER(10)", Some("0")),
6004                ("SEQUENCE", "NUMBER(10)", Some("1")),
6005                ("DATA_TYPE", "VARCHAR2(30)", Some("NUMBER")),
6006                ("TYPE_OWNER", "VARCHAR2(128)", None),
6007                ("TYPE_NAME", "VARCHAR2(128)", None),
6008                ("DATA_LENGTH", "NUMBER(10)", Some("22")),
6009                ("DATA_PRECISION", "NUMBER(10)", Some("10")),
6010                ("DATA_SCALE", "NUMBER(10)", Some("2")),
6011                ("IN_OUT", "VARCHAR2(9)", Some("OUT")),
6012                ("DEFAULTED", "VARCHAR2(1)", Some("N")),
6013            ]),
6014            oracle_row(&[
6015                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
6016                ("PACKAGE_NAME", "VARCHAR2(128)", None),
6017                ("OBJECT_NAME", "VARCHAR2(128)", Some("CALCULATE_TAX")),
6018                ("SUBPROGRAM_ID", "NUMBER(10)", Some("1")),
6019                ("OVERLOAD", "VARCHAR2(40)", None),
6020                ("ARGUMENT_NAME", "VARCHAR2(128)", Some("INVOICE_ID")),
6021                ("POSITION", "NUMBER(10)", Some("1")),
6022                ("SEQUENCE", "NUMBER(10)", Some("2")),
6023                ("DATA_TYPE", "VARCHAR2(30)", Some("NUMBER")),
6024                ("TYPE_OWNER", "VARCHAR2(128)", None),
6025                ("TYPE_NAME", "VARCHAR2(128)", None),
6026                ("DATA_LENGTH", "NUMBER(10)", Some("22")),
6027                ("DATA_PRECISION", "NUMBER(10)", Some("10")),
6028                ("DATA_SCALE", "NUMBER(10)", Some("0")),
6029                ("IN_OUT", "VARCHAR2(9)", Some("IN")),
6030                ("DEFAULTED", "VARCHAR2(1)", Some("N")),
6031            ]),
6032        ];
6033        let mut expected_queries = capability_probe_expectations();
6034        // REPORTING is a database user, so its object grant stays a direct
6035        // (high-confidence) Grantee::User after the role-classification fix.
6036        expected_queries.push(all_users_expectation(&["BILLING", "REPORTING"]));
6037        expected_queries.extend(vec![
6038                QueryExpectation {
6039                    sql_contains: String::from("from all_objects"),
6040                    params: vec![OracleBind::from("BILLING")],
6041                    rows: object_rows,
6042                },
6043                QueryExpectation {
6044                    sql_contains: String::from("from all_tab_cols"),
6045                    params: vec![OracleBind::from("BILLING")],
6046                    rows: column_rows,
6047                },
6048                QueryExpectation {
6049                    sql_contains: String::from("from all_constraints c"),
6050                    params: vec![OracleBind::from("BILLING")],
6051                    rows: constraint_rows,
6052                },
6053                QueryExpectation {
6054                    sql_contains: String::from("from all_indexes i"),
6055                    params: vec![OracleBind::from("BILLING")],
6056                    rows: index_rows,
6057                },
6058                QueryExpectation {
6059                    sql_contains: String::from("from all_triggers"),
6060                    params: vec![OracleBind::from("BILLING")],
6061                    rows: trigger_rows,
6062                },
6063                QueryExpectation {
6064                    sql_contains: String::from("from all_synonyms"),
6065                    params: vec![OracleBind::from("BILLING")],
6066                    rows: synonym_rows,
6067                },
6068                QueryExpectation {
6069                    sql_contains: String::from("from all_procedures"),
6070                    params: vec![OracleBind::from("BILLING")],
6071                    rows: procedure_rows,
6072                },
6073                QueryExpectation {
6074                    sql_contains: String::from("from all_arguments"),
6075                    params: vec![OracleBind::from("BILLING")],
6076                    rows: argument_rows,
6077                },
6078                QueryExpectation {
6079                    sql_contains: String::from("from all_views"),
6080                    params: vec![OracleBind::from("BILLING")],
6081                    rows: vec![oracle_row(&[
6082                        ("OWNER", "VARCHAR2(128)", Some("BILLING")),
6083                        ("VIEW_NAME", "VARCHAR2(128)", Some("INVOICE_SUMMARY")),
6084                        (
6085                            "TEXT_VC",
6086                            "VARCHAR2(4000)",
6087                            Some(
6088                                "select invoice_id, sum(amount) total_due from invoices group by invoice_id",
6089                            ),
6090                        ),
6091                        ("READ_ONLY", "VARCHAR2(1)", Some("N")),
6092                    ])],
6093                },
6094                QueryExpectation {
6095                    sql_contains: String::from("from all_mviews"),
6096                    params: vec![OracleBind::from("BILLING")],
6097                    rows: vec![],
6098                },
6099                QueryExpectation {
6100                    sql_contains: String::from("from all_sequences"),
6101                    params: vec![OracleBind::from("BILLING")],
6102                    rows: vec![],
6103                },
6104                QueryExpectation {
6105                    sql_contains: String::from("from all_type_attrs"),
6106                    params: vec![OracleBind::from("BILLING")],
6107                    rows: vec![],
6108                },
6109                QueryExpectation {
6110                    sql_contains: String::from("from all_tab_privs"),
6111                    params: vec![OracleBind::from("BILLING")],
6112                    rows: vec![oracle_row(&[
6113                        ("TABLE_SCHEMA", "VARCHAR2(128)", Some("BILLING")),
6114                        ("TABLE_NAME", "VARCHAR2(128)", Some("INVOICES")),
6115                        ("GRANTEE", "VARCHAR2(128)", Some("REPORTING")),
6116                        ("PRIVILEGE", "VARCHAR2(40)", Some("SELECT")),
6117                        ("GRANTABLE", "VARCHAR2(3)", Some("NO")),
6118                        ("HIERARCHY", "VARCHAR2(3)", Some("NO")),
6119                    ])],
6120                },
6121                QueryExpectation {
6122                    sql_contains: String::from("from all_db_links"),
6123                    params: vec![OracleBind::from("BILLING")],
6124                    rows: vec![],
6125                },
6126                QueryExpectation {
6127                    sql_contains: String::from("from all_tab_comments"),
6128                    params: vec![OracleBind::from("BILLING")],
6129                    rows: vec![],
6130                },
6131                QueryExpectation {
6132                    sql_contains: String::from("from all_col_comments"),
6133                    params: vec![OracleBind::from("BILLING")],
6134                    rows: vec![],
6135                },
6136                QueryExpectation {
6137                    sql_contains: String::from("from all_editions"),
6138                    params: vec![],
6139                    rows: vec![],
6140                },
6141                QueryExpectation {
6142                    sql_contains: String::from("from all_editioning_views"),
6143                    params: vec![OracleBind::from("BILLING")],
6144                    rows: vec![],
6145                },
6146                QueryExpectation {
6147                    sql_contains: String::from("from all_policies"),
6148                    params: vec![OracleBind::from("BILLING")],
6149                    rows: vec![],
6150                },
6151                QueryExpectation {
6152                    sql_contains: String::from("from all_dependencies"),
6153                    params: vec![OracleBind::from("BILLING")],
6154                    rows: vec![],
6155                },
6156        ]);
6157        let connection = StaticConnection {
6158            expected_queries,
6159            ..StaticConnection::default()
6160        };
6161
6162        let snapshot = load_snapshot_for_test(&connection, &CatalogLoadRequest::default()).unwrap();
6163
6164        let current_schema = snapshot
6165            .profile
6166            .current_schema
6167            .and_then(|name| snapshot.interner.resolve(name.symbol()));
6168        assert_eq!(current_schema, Some("BILLING"));
6169        assert_eq!(
6170            snapshot.profile.oracle_version,
6171            plsql_core::OracleVersion::Oracle23ai
6172        );
6173        assert!(snapshot.capabilities.can_query_all_views);
6174
6175        let billing_schema = snapshot
6176            .profile
6177            .current_schema
6178            .expect("current schema should be interned");
6179        let schema_catalog = snapshot
6180            .schemas
6181            .get(&billing_schema)
6182            .expect("billing schema should exist");
6183
6184        let invoices_name = schema_catalog
6185            .objects
6186            .keys()
6187            .find(|name| {
6188                snapshot
6189                    .interner
6190                    .resolve(name.symbol())
6191                    .is_some_and(|label| label.eq("INVOICES"))
6192            })
6193            .copied()
6194            .expect("object name should intern");
6195        let invoice_summary_name = schema_catalog
6196            .objects
6197            .keys()
6198            .find(|name| {
6199                snapshot
6200                    .interner
6201                    .resolve(name.symbol())
6202                    .is_some_and(|label| label.eq("INVOICE_SUMMARY"))
6203            })
6204            .copied()
6205            .expect("object name should intern");
6206        let package_object_name = schema_catalog
6207            .objects
6208            .keys()
6209            .find(|name| {
6210                snapshot
6211                    .interner
6212                    .resolve(name.symbol())
6213                    .is_some_and(|label| label.eq("BILLING_API"))
6214            })
6215            .copied()
6216            .expect("package object name should intern");
6217        let tax_function_name = schema_catalog
6218            .objects
6219            .keys()
6220            .find(|name| {
6221                snapshot
6222                    .interner
6223                    .resolve(name.symbol())
6224                    .is_some_and(|label| label.eq("CALCULATE_TAX"))
6225            })
6226            .copied()
6227            .expect("function object name should intern");
6228        let trigger_object_name = schema_catalog
6229            .objects
6230            .keys()
6231            .find(|name| {
6232                snapshot
6233                    .interner
6234                    .resolve(name.symbol())
6235                    .is_some_and(|label| label.eq("INVOICES_BIU"))
6236            })
6237            .copied()
6238            .expect("trigger object name should intern");
6239
6240        let invoices_table = schema_catalog
6241            .objects
6242            .get(&invoices_name)
6243            .and_then(|object| {
6244                if let CatalogObject::Table(table) = object {
6245                    Some(table)
6246                } else {
6247                    None
6248                }
6249            });
6250        assert!(invoices_table.is_some());
6251        let invoice_id = invoices_table
6252            .and_then(|table| {
6253                table.columns.values().find(|column| {
6254                    snapshot
6255                        .interner
6256                        .resolve(column.name.symbol())
6257                        .is_some_and(|label| label.eq("INVOICE_ID"))
6258                })
6259            })
6260            .expect("table column should exist");
6261        assert_eq!(invoice_id.position, 1);
6262        assert_eq!(invoice_id.data_type.name, "NUMBER");
6263        assert_eq!(invoice_id.data_type.precision, Some(10));
6264        assert!(!invoice_id.nullable);
6265        assert!(!invoice_id.hidden);
6266        assert!(invoice_id.generated_expression.is_none());
6267
6268        let invoice_summary_view =
6269            schema_catalog
6270                .objects
6271                .get(&invoice_summary_name)
6272                .and_then(|object| {
6273                    if let CatalogObject::View(view) = object {
6274                        Some(view)
6275                    } else {
6276                        None
6277                    }
6278                });
6279        assert!(invoice_summary_view.is_some());
6280        let total_due = invoice_summary_view
6281            .and_then(|view| {
6282                view.columns.values().find(|column| {
6283                    snapshot
6284                        .interner
6285                        .resolve(column.name.symbol())
6286                        .is_some_and(|label| label.eq("TOTAL_DUE"))
6287                })
6288            })
6289            .expect("view column should exist");
6290        assert_eq!(total_due.position, 1);
6291        assert!(total_due.nullable);
6292        assert_eq!(total_due.generated_expression.as_deref(), Some("0"));
6293        assert!(total_due.default_expression.is_none());
6294
6295        let invoices_pk_index = schema_catalog
6296            .indexes
6297            .values()
6298            .find(|index| {
6299                snapshot
6300                    .interner
6301                    .resolve(index.name.symbol())
6302                    .is_some_and(|label| label.eq("INVOICES_PK_IDX"))
6303            })
6304            .expect("index metadata should exist");
6305        assert!(invoices_pk_index.unique);
6306        assert_eq!(invoices_pk_index.index_type, "NORMAL");
6307        assert_eq!(invoices_pk_index.status, ObjectStatus::Valid);
6308        assert_eq!(
6309            invoices_pk_index
6310                .columns
6311                .first()
6312                .and_then(|column| snapshot.interner.resolve(column.symbol())),
6313            Some("INVOICE_ID")
6314        );
6315
6316        let customer_fk = schema_catalog
6317            .constraints
6318            .values()
6319            .find(|constraint| {
6320                snapshot
6321                    .interner
6322                    .resolve(constraint.name.symbol())
6323                    .is_some_and(|label| label.eq("INVOICES_CUSTOMER_FK"))
6324            })
6325            .expect("foreign key should exist");
6326        assert_eq!(customer_fk.constraint_type, ConstraintType::ForeignKey);
6327        assert_eq!(
6328            customer_fk
6329                .columns
6330                .first()
6331                .and_then(|column| snapshot.interner.resolve(column.symbol())),
6332            Some("CUSTOMER_ID")
6333        );
6334        assert_eq!(
6335            customer_fk
6336                .referenced_table_name
6337                .and_then(|name| snapshot.interner.resolve(name.symbol())),
6338            Some("CUSTOMERS")
6339        );
6340        assert_eq!(
6341            customer_fk
6342                .referenced_columns
6343                .first()
6344                .and_then(|column| snapshot.interner.resolve(column.symbol())),
6345            Some("CUSTOMER_ID")
6346        );
6347
6348        let customer_not_null = schema_catalog
6349            .constraints
6350            .values()
6351            .find(|constraint| {
6352                snapshot
6353                    .interner
6354                    .resolve(constraint.name.symbol())
6355                    .is_some_and(|label| label.eq("INVOICES_CUSTOMER_NN"))
6356            })
6357            .expect("not-null constraint should exist");
6358        assert_eq!(customer_not_null.constraint_type, ConstraintType::NotNull);
6359
6360        let trigger_metadata = schema_catalog
6361            .triggers
6362            .values()
6363            .find(|trigger| {
6364                snapshot
6365                    .interner
6366                    .resolve(trigger.common.name.symbol())
6367                    .is_some_and(|label| label.eq("INVOICES_BIU"))
6368            })
6369            .expect("trigger metadata should exist");
6370        assert_eq!(trigger_metadata.timing, TriggerTiming::Before);
6371        assert_eq!(trigger_metadata.level, TriggerLevel::Row);
6372        assert_eq!(
6373            trigger_metadata.events.as_slice(),
6374            &[TriggerEvent::Insert, TriggerEvent::Update]
6375        );
6376        assert_eq!(
6377            trigger_metadata.target_name.symbol().get(),
6378            invoices_name.symbol().get()
6379        );
6380        assert_eq!(
6381            trigger_metadata.when_clause.as_deref(),
6382            Some(":new.total_due >= 0")
6383        );
6384        assert!(matches!(
6385            schema_catalog.objects.get(&trigger_object_name),
6386            Some(CatalogObject::Trigger(_))
6387        ));
6388
6389        let latest_invoice_synonym = schema_catalog
6390            .synonyms
6391            .values()
6392            .find(|synonym| {
6393                snapshot
6394                    .interner
6395                    .resolve(synonym.target_name.symbol())
6396                    .is_some_and(|label| label.eq("INVOICES"))
6397            })
6398            .expect("private synonym should exist");
6399        assert!(!latest_invoice_synonym.public_synonym);
6400
6401        let public_schema_name = snapshot
6402            .schemas
6403            .keys()
6404            .find(|name| {
6405                snapshot
6406                    .interner
6407                    .resolve(name.symbol())
6408                    .is_some_and(|label| label.eq("PUBLIC"))
6409            })
6410            .copied()
6411            .expect("public schema should exist");
6412        let public_schema = snapshot
6413            .schemas
6414            .get(&public_schema_name)
6415            .expect("public schema catalog should exist");
6416        let public_synonym = public_schema
6417            .synonyms
6418            .values()
6419            .find(|synonym| {
6420                snapshot
6421                    .interner
6422                    .resolve(synonym.target_name.symbol())
6423                    .is_some_and(|label| label.eq("INVOICES"))
6424            })
6425            .expect("public synonym should exist");
6426        assert!(public_synonym.public_synonym);
6427
6428        let package_metadata = schema_catalog
6429            .objects
6430            .get(&package_object_name)
6431            .and_then(|object| {
6432                if let CatalogObject::Package(metadata) = object {
6433                    Some(metadata)
6434                } else {
6435                    None
6436                }
6437            })
6438            .expect("package metadata should exist");
6439        let create_invoice = package_metadata
6440            .procedures
6441            .iter()
6442            .find(|signature| {
6443                snapshot
6444                    .interner
6445                    .resolve(signature.routine_name.symbol())
6446                    .is_some_and(|label| label.eq("CREATE_INVOICE"))
6447            })
6448            .expect("package procedure should exist");
6449        assert_eq!(create_invoice.arguments.len(), 2);
6450        let total_for_customer = package_metadata
6451            .functions
6452            .iter()
6453            .find(|signature| {
6454                snapshot
6455                    .interner
6456                    .resolve(signature.routine_name.symbol())
6457                    .is_some_and(|label| label.eq("TOTAL_FOR_CUSTOMER"))
6458            })
6459            .expect("package function should exist");
6460        assert_eq!(total_for_customer.overload, Some(1));
6461        assert_eq!(
6462            total_for_customer
6463                .return_type
6464                .as_ref()
6465                .map(|data_type| data_type.name.as_str()),
6466            Some("NUMBER")
6467        );
6468        assert_eq!(total_for_customer.arguments.len(), 1);
6469
6470        let tax_function = schema_catalog
6471            .objects
6472            .get(&tax_function_name)
6473            .and_then(|object| {
6474                if let CatalogObject::Function(metadata) = object {
6475                    Some(metadata)
6476                } else {
6477                    None
6478                }
6479            })
6480            .expect("top-level function should exist");
6481        assert!(tax_function.deterministic);
6482        assert!(!tax_function.pipelined);
6483        assert_eq!(tax_function.signature.arguments.len(), 1);
6484        assert_eq!(
6485            tax_function
6486                .signature
6487                .return_type
6488                .as_ref()
6489                .map(|data_type| data_type.name.as_str()),
6490            Some("NUMBER")
6491        );
6492
6493        let invoice_summary_view_with_hash = schema_catalog
6494            .objects
6495            .get(&invoice_summary_name)
6496            .and_then(|object| {
6497                if let CatalogObject::View(view) = object {
6498                    Some(view)
6499                } else {
6500                    None
6501                }
6502            })
6503            .expect("invoice summary view should be present");
6504        assert!(invoice_summary_view_with_hash.query_hash.is_some());
6505        assert_eq!(invoice_summary_view_with_hash.read_only, Some(false));
6506
6507        assert_eq!(schema_catalog.grants.len(), 1);
6508        let reporting_grant = &schema_catalog.grants[0];
6509        assert!(matches!(
6510            reporting_grant.privilege,
6511            crate::GrantPrivilege::Select
6512        ));
6513        // REPORTING appears in the ALL_USERS fixture, so it classifies as a
6514        // direct user grant (and not, conservatively, as a role).
6515        assert!(matches!(reporting_grant.grantee, crate::Grantee::User(_)));
6516        assert!(!reporting_grant.grantable);
6517    }
6518
6519    /// oracle-qm3q.2 regression: `grantee_from_dictionary_value` must
6520    /// discriminate an object-privilege grantee against the loaded
6521    /// `ALL_USERS` set. A grantee that is NOT a known user is a database
6522    /// role (the only remaining grantee class besides PUBLIC), and must be
6523    /// recorded as `Grantee::Role` so the privilege resolver downgrades it
6524    /// to Low confidence with a `RuntimeGrantOrRole` ambiguity instead of
6525    /// the previous, fail-toward-permissive `Grantee::User` (High).
6526    #[test]
6527    fn grantee_classification_uses_loaded_user_set() {
6528        let mut snapshot = CatalogSnapshot::new(
6529            plsql_core::AnalysisProfile::default(),
6530            CatalogCapabilities::default(),
6531            CatalogSource::default(),
6532            DateTime::<Utc>::UNIX_EPOCH,
6533        );
6534
6535        // Before the user set is loaded, the grantee class is undetermined.
6536        // R13 / fail-toward-restrictive: an undetermined grantee must NOT be
6537        // a high-confidence direct user grant — it routes through the role
6538        // ambiguity path instead.
6539        assert!(snapshot.known_users.is_none());
6540        let undetermined =
6541            grantee_from_dictionary_value(&mut snapshot, "MYSTERY_GRANTEE").expect("grantee");
6542        assert!(
6543            matches!(undetermined, crate::Grantee::Role(_)),
6544            "undetermined grantee (ALL_USERS not loaded) must not be a direct user grant; got {undetermined:?}"
6545        );
6546
6547        // Load a user set: APP_USER is a user, APP_READER_ROLE is not.
6548        let app_user = snapshot.intern_user_name("APP_USER").expect("user");
6549        let mut users = HashSet::new();
6550        users.insert(app_user);
6551        snapshot.known_users = Some(users);
6552
6553        // PUBLIC is always PUBLIC.
6554        assert!(matches!(
6555            grantee_from_dictionary_value(&mut snapshot, "PUBLIC").expect("grantee"),
6556            crate::Grantee::Public
6557        ));
6558        // A known user classifies as a direct user grant.
6559        assert!(matches!(
6560            grantee_from_dictionary_value(&mut snapshot, "APP_USER").expect("grantee"),
6561            crate::Grantee::User(_)
6562        ));
6563        // A grantee absent from ALL_USERS classifies as a role — the defect
6564        // this bead fixes (previously always `Grantee::User`).
6565        let role =
6566            grantee_from_dictionary_value(&mut snapshot, "APP_READER_ROLE").expect("grantee");
6567        let role_name = match role {
6568            crate::Grantee::Role(role_name) => Some(role_name),
6569            _ => None,
6570        };
6571        assert!(
6572            role_name.is_some(),
6573            "APP_READER_ROLE must classify as a role"
6574        );
6575        assert_eq!(
6576            snapshot
6577                .interner
6578                .resolve(role_name.expect("role assertion above").symbol()),
6579            Some("APP_READER_ROLE")
6580        );
6581    }
6582
6583    /// oracle-qm3q.2: if `ALL_USERS` cannot be read, `load_catalog_users`
6584    /// must leave `known_users` as `None` (so grantees stay conservatively
6585    /// classified) and record a capability warning rather than aborting the
6586    /// extraction or silently assuming the grantee universe.
6587    #[test]
6588    fn load_catalog_users_failure_is_nonfatal_and_marks_unknown() {
6589        // A strict mock with NO matching expectation for `from all_users`
6590        // makes the query fail.
6591        let connection = StaticConnection {
6592            expected_queries: vec![QueryExpectation {
6593                sql_contains: String::from("from something_else"),
6594                params: vec![],
6595                rows: vec![],
6596            }],
6597            ..StaticConnection::default()
6598        };
6599        let mut snapshot = CatalogSnapshot::new(
6600            plsql_core::AnalysisProfile::default(),
6601            CatalogCapabilities::default(),
6602            CatalogSource::default(),
6603            DateTime::<Utc>::UNIX_EPOCH,
6604        );
6605
6606        load_catalog_users_for_test(&connection, &mut snapshot).expect("non-fatal");
6607        assert!(
6608            snapshot.known_users.is_none(),
6609            "failed ALL_USERS read must leave grantee universe undetermined"
6610        );
6611        assert!(
6612            snapshot
6613                .capabilities
6614                .warnings
6615                .iter()
6616                .any(|w| w.code.eq("all-users-probe")),
6617            "a capability warning must record the ALL_USERS read failure"
6618        );
6619    }
6620
6621    #[test]
6622    fn load_snapshot_from_connection_extracts_views_sequences_mviews_types_and_grants() {
6623        let object_rows = vec![
6624            oracle_row(&[
6625                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
6626                ("OBJECT_NAME", "VARCHAR2(128)", Some("ACTIVE_CUSTOMERS")),
6627                ("OBJECT_TYPE", "VARCHAR2(30)", Some("VIEW")),
6628                ("STATUS", "VARCHAR2(7)", Some("VALID")),
6629            ]),
6630            oracle_row(&[
6631                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
6632                ("OBJECT_NAME", "VARCHAR2(128)", Some("CUSTOMER_TOTALS_MV")),
6633                ("OBJECT_TYPE", "VARCHAR2(30)", Some("MATERIALIZED VIEW")),
6634                ("STATUS", "VARCHAR2(7)", Some("VALID")),
6635            ]),
6636            oracle_row(&[
6637                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
6638                ("OBJECT_NAME", "VARCHAR2(128)", Some("INVOICE_SEQ")),
6639                ("OBJECT_TYPE", "VARCHAR2(30)", Some("SEQUENCE")),
6640                ("STATUS", "VARCHAR2(7)", Some("VALID")),
6641            ]),
6642            oracle_row(&[
6643                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
6644                ("OBJECT_NAME", "VARCHAR2(128)", Some("ADDRESS_T")),
6645                ("OBJECT_TYPE", "VARCHAR2(30)", Some("TYPE")),
6646                ("STATUS", "VARCHAR2(7)", Some("VALID")),
6647            ]),
6648        ];
6649        let view_rows = vec![oracle_row(&[
6650            ("OWNER", "VARCHAR2(128)", Some("BILLING")),
6651            ("VIEW_NAME", "VARCHAR2(128)", Some("ACTIVE_CUSTOMERS")),
6652            (
6653                "TEXT_VC",
6654                "VARCHAR2(4000)",
6655                Some("select customer_id from customers where active = 'Y'"),
6656            ),
6657            ("READ_ONLY", "VARCHAR2(1)", Some("Y")),
6658        ])];
6659        let mview_rows = vec![oracle_row(&[
6660            ("OWNER", "VARCHAR2(128)", Some("BILLING")),
6661            ("MVIEW_NAME", "VARCHAR2(128)", Some("CUSTOMER_TOTALS_MV")),
6662            ("REFRESH_MODE", "VARCHAR2(6)", Some("DEMAND")),
6663            ("REFRESH_METHOD", "VARCHAR2(8)", Some("COMPLETE")),
6664            (
6665                "QUERY",
6666                "LONG",
6667                Some("select customer_id, sum(amount) from invoices group by customer_id"),
6668            ),
6669        ])];
6670        let sequence_rows = vec![oracle_row(&[
6671            ("SEQUENCE_OWNER", "VARCHAR2(128)", Some("BILLING")),
6672            ("SEQUENCE_NAME", "VARCHAR2(128)", Some("INVOICE_SEQ")),
6673            ("MIN_VALUE", "NUMBER(28)", Some("1")),
6674            ("MAX_VALUE", "NUMBER(28)", Some("9999999999")),
6675            ("INCREMENT_BY", "NUMBER(28)", Some("1")),
6676            ("CYCLE_FLAG", "VARCHAR2(1)", Some("N")),
6677            ("ORDER_FLAG", "VARCHAR2(1)", Some("N")),
6678            ("CACHE_SIZE", "NUMBER(28)", Some("20")),
6679        ])];
6680        let type_attr_rows = vec![
6681            oracle_row(&[
6682                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
6683                ("TYPE_NAME", "VARCHAR2(128)", Some("ADDRESS_T")),
6684                ("ATTR_NAME", "VARCHAR2(128)", Some("STREET")),
6685                ("ATTR_NO", "NUMBER(10)", Some("1")),
6686                ("ATTR_TYPE_OWNER", "VARCHAR2(128)", None),
6687                ("ATTR_TYPE_NAME", "VARCHAR2(128)", Some("VARCHAR2")),
6688                ("LENGTH", "NUMBER(10)", Some("200")),
6689                ("PRECISION", "NUMBER(10)", None),
6690                ("SCALE", "NUMBER(10)", None),
6691            ]),
6692            oracle_row(&[
6693                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
6694                ("TYPE_NAME", "VARCHAR2(128)", Some("ADDRESS_T")),
6695                ("ATTR_NAME", "VARCHAR2(128)", Some("ZIP")),
6696                ("ATTR_NO", "NUMBER(10)", Some("2")),
6697                ("ATTR_TYPE_OWNER", "VARCHAR2(128)", None),
6698                ("ATTR_TYPE_NAME", "VARCHAR2(128)", Some("VARCHAR2")),
6699                ("LENGTH", "NUMBER(10)", Some("10")),
6700                ("PRECISION", "NUMBER(10)", None),
6701                ("SCALE", "NUMBER(10)", None),
6702            ]),
6703        ];
6704        let grant_rows = vec![
6705            oracle_row(&[
6706                ("TABLE_SCHEMA", "VARCHAR2(128)", Some("BILLING")),
6707                ("TABLE_NAME", "VARCHAR2(128)", Some("ACTIVE_CUSTOMERS")),
6708                ("GRANTEE", "VARCHAR2(128)", Some("PUBLIC")),
6709                ("PRIVILEGE", "VARCHAR2(40)", Some("SELECT")),
6710                ("GRANTABLE", "VARCHAR2(3)", Some("NO")),
6711                ("HIERARCHY", "VARCHAR2(3)", Some("NO")),
6712            ]),
6713            oracle_row(&[
6714                ("TABLE_SCHEMA", "VARCHAR2(128)", Some("BILLING")),
6715                ("TABLE_NAME", "VARCHAR2(128)", Some("ACTIVE_CUSTOMERS")),
6716                ("GRANTEE", "VARCHAR2(128)", Some("REPORTING_ROLE")),
6717                ("PRIVILEGE", "VARCHAR2(40)", Some("UPDATE")),
6718                ("GRANTABLE", "VARCHAR2(3)", Some("YES")),
6719                ("HIERARCHY", "VARCHAR2(3)", Some("NO")),
6720            ]),
6721        ];
6722
6723        // PLSQL-CAT-NEW-3 / oracle-fmro: one root edition + one child
6724        // edition + one editioning view to exercise both EBR paths.
6725        let edition_rows = vec![
6726            oracle_row(&[
6727                ("EDITION_NAME", "VARCHAR2(128)", Some("ORA$BASE")),
6728                ("PARENT_EDITION_NAME", "VARCHAR2(128)", None),
6729                ("USABLE", "VARCHAR2(1)", Some("Y")),
6730            ]),
6731            oracle_row(&[
6732                ("EDITION_NAME", "VARCHAR2(128)", Some("PATCH_2026_05")),
6733                ("PARENT_EDITION_NAME", "VARCHAR2(128)", Some("ORA$BASE")),
6734                ("USABLE", "VARCHAR2(1)", Some("Y")),
6735            ]),
6736        ];
6737        let editioning_view_rows = vec![oracle_row(&[
6738            ("OWNER", "VARCHAR2(128)", Some("BILLING")),
6739            ("VIEW_NAME", "VARCHAR2(128)", Some("INVOICES_E1")),
6740            ("TABLE_NAME", "VARCHAR2(128)", Some("INVOICES")),
6741        ])];
6742
6743        // PLSQL-CAT-NEW-2 / oracle-c0gg: one VPD policy that gates
6744        // SELECT-only on INVOICES — exercises the yn() column reader
6745        // and the SchemaCatalog::vpd_policies path.
6746        let vpd_policy_rows = vec![oracle_row(&[
6747            ("OBJECT_OWNER", "VARCHAR2(128)", Some("BILLING")),
6748            ("OBJECT_NAME", "VARCHAR2(128)", Some("INVOICES")),
6749            ("POLICY_GROUP", "VARCHAR2(128)", None),
6750            (
6751                "POLICY_NAME",
6752                "VARCHAR2(128)",
6753                Some("BILLING_TENANT_ISOLATION"),
6754            ),
6755            ("PF_OWNER", "VARCHAR2(128)", Some("SECURITY")),
6756            ("PACKAGE", "VARCHAR2(128)", Some("RLS_PKG")),
6757            ("FUNCTION", "VARCHAR2(128)", Some("TENANT_PREDICATE")),
6758            ("SEL", "VARCHAR2(3)", Some("YES")),
6759            ("INS", "VARCHAR2(3)", Some("NO")),
6760            ("UPD", "VARCHAR2(3)", Some("NO")),
6761            ("DEL", "VARCHAR2(3)", Some("NO")),
6762            ("ENABLE", "VARCHAR2(3)", Some("YES")),
6763        ])];
6764
6765        // PLSQL-CAT-NEW-5 / oracle-grs0: one table comment + one column
6766        // comment exercise both apply_*_comment_row paths.
6767        let table_comment_rows = vec![oracle_row(&[
6768            ("OWNER", "VARCHAR2(128)", Some("BILLING")),
6769            ("TABLE_NAME", "VARCHAR2(128)", Some("INVOICES")),
6770            ("TABLE_TYPE", "VARCHAR2(11)", Some("TABLE")),
6771            (
6772                "COMMENTS",
6773                "VARCHAR2(4000)",
6774                Some("Customer invoice header rows; one per invoice"),
6775            ),
6776        ])];
6777        let column_comment_rows = vec![oracle_row(&[
6778            ("OWNER", "VARCHAR2(128)", Some("BILLING")),
6779            ("TABLE_NAME", "VARCHAR2(128)", Some("INVOICES")),
6780            ("COLUMN_NAME", "VARCHAR2(128)", Some("INVOICE_ID")),
6781            (
6782                "COMMENTS",
6783                "VARCHAR2(4000)",
6784                Some("Primary key; surrogate, allocated from INVOICES_SEQ"),
6785            ),
6786        ])];
6787
6788        // One private link (BILLING.REPORTING_LINK) and one public link
6789        // (PUBLIC.WORLDWIDE_LINK) — exercises both code paths in
6790        // `apply_db_link_row` (PLSQL-CAT-NEW-1 / oracle-rr4y).
6791        let db_link_rows = vec![
6792            oracle_row(&[
6793                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
6794                ("DB_LINK", "VARCHAR2(128)", Some("REPORTING_LINK")),
6795                ("HOST", "VARCHAR2(2000)", Some("reporting-db.internal")),
6796            ]),
6797            oracle_row(&[
6798                ("OWNER", "VARCHAR2(128)", Some("PUBLIC")),
6799                ("DB_LINK", "VARCHAR2(128)", Some("WORLDWIDE_LINK")),
6800                ("HOST", "VARCHAR2(2000)", Some("//world.example.com/PROD")),
6801            ]),
6802        ];
6803
6804        let billing_only = vec![OracleBind::from("BILLING")];
6805        let mut expected_queries = capability_probe_expectations();
6806        // Only BILLING is a real user; REPORTING_ROLE is deliberately absent
6807        // so the UPDATE grant to it classifies as a role, not a user.
6808        expected_queries.push(all_users_expectation(&["BILLING"]));
6809        expected_queries.extend(vec![
6810            QueryExpectation {
6811                sql_contains: String::from("from all_objects"),
6812                params: billing_only.clone(),
6813                rows: object_rows,
6814            },
6815            QueryExpectation {
6816                sql_contains: String::from("from all_tab_cols"),
6817                params: billing_only.clone(),
6818                rows: vec![],
6819            },
6820            QueryExpectation {
6821                sql_contains: String::from("from all_constraints c"),
6822                params: billing_only.clone(),
6823                rows: vec![],
6824            },
6825            QueryExpectation {
6826                sql_contains: String::from("from all_indexes i"),
6827                params: billing_only.clone(),
6828                rows: vec![],
6829            },
6830            QueryExpectation {
6831                sql_contains: String::from("from all_triggers"),
6832                params: billing_only.clone(),
6833                rows: vec![],
6834            },
6835            QueryExpectation {
6836                sql_contains: String::from("from all_synonyms"),
6837                params: billing_only.clone(),
6838                rows: vec![],
6839            },
6840            QueryExpectation {
6841                sql_contains: String::from("from all_procedures"),
6842                params: billing_only.clone(),
6843                rows: vec![],
6844            },
6845            QueryExpectation {
6846                sql_contains: String::from("from all_arguments"),
6847                params: billing_only.clone(),
6848                rows: vec![],
6849            },
6850            QueryExpectation {
6851                sql_contains: String::from("from all_views"),
6852                params: billing_only.clone(),
6853                rows: view_rows,
6854            },
6855            QueryExpectation {
6856                sql_contains: String::from("from all_mviews"),
6857                params: billing_only.clone(),
6858                rows: mview_rows,
6859            },
6860            QueryExpectation {
6861                sql_contains: String::from("from all_sequences"),
6862                params: billing_only.clone(),
6863                rows: sequence_rows,
6864            },
6865            QueryExpectation {
6866                sql_contains: String::from("from all_type_attrs"),
6867                params: billing_only.clone(),
6868                rows: type_attr_rows,
6869            },
6870            QueryExpectation {
6871                sql_contains: String::from("from all_tab_privs"),
6872                params: billing_only.clone(),
6873                rows: grant_rows,
6874            },
6875            QueryExpectation {
6876                sql_contains: String::from("from all_db_links"),
6877                params: billing_only.clone(),
6878                rows: db_link_rows,
6879            },
6880            QueryExpectation {
6881                sql_contains: String::from("from all_tab_comments"),
6882                params: billing_only.clone(),
6883                rows: table_comment_rows,
6884            },
6885            QueryExpectation {
6886                sql_contains: String::from("from all_col_comments"),
6887                params: billing_only.clone(),
6888                rows: column_comment_rows,
6889            },
6890            QueryExpectation {
6891                sql_contains: String::from("from all_editions"),
6892                params: vec![],
6893                rows: edition_rows,
6894            },
6895            QueryExpectation {
6896                sql_contains: String::from("from all_editioning_views"),
6897                params: billing_only.clone(),
6898                rows: editioning_view_rows,
6899            },
6900            QueryExpectation {
6901                sql_contains: String::from("from all_policies"),
6902                params: billing_only.clone(),
6903                rows: vpd_policy_rows,
6904            },
6905            QueryExpectation {
6906                sql_contains: String::from("from all_dependencies"),
6907                params: billing_only,
6908                rows: vec![],
6909            },
6910        ]);
6911        let connection = StaticConnection {
6912            expected_queries,
6913            ..StaticConnection::default()
6914        };
6915
6916        let snapshot = load_snapshot_for_test(&connection, &CatalogLoadRequest::default()).unwrap();
6917
6918        let schema = snapshot
6919            .profile
6920            .current_schema
6921            .expect("current schema interned");
6922        let schema_catalog = snapshot
6923            .schemas
6924            .get(&schema)
6925            .expect("billing schema catalog should exist");
6926
6927        let view = schema_catalog
6928            .objects
6929            .values()
6930            .find_map(|object| match object {
6931                CatalogObject::View(view) => Some(view),
6932                _ => None,
6933            })
6934            .expect("view metadata present");
6935        assert!(view.query_hash.is_some());
6936        assert_eq!(view.read_only, Some(true));
6937
6938        let mview = schema_catalog
6939            .objects
6940            .values()
6941            .find_map(|object| match object {
6942                CatalogObject::MaterializedView(metadata) => Some(metadata),
6943                _ => None,
6944            })
6945            .expect("mview metadata present");
6946        assert_eq!(mview.refresh_mode.as_deref(), Some("DEMAND"));
6947        assert_eq!(mview.refresh_method.as_deref(), Some("COMPLETE"));
6948        assert!(mview.query_hash.is_some());
6949
6950        let sequence = schema_catalog
6951            .objects
6952            .values()
6953            .find_map(|object| match object {
6954                CatalogObject::Sequence(metadata) => Some(metadata),
6955                _ => None,
6956            })
6957            .expect("sequence metadata present");
6958        assert_eq!(sequence.increment_by, 1);
6959        assert_eq!(sequence.min_value, Some(1));
6960        assert_eq!(sequence.max_value, Some(9999999999));
6961        assert!(!sequence.cycle);
6962        assert!(!sequence.ordered);
6963        assert_eq!(sequence.cache_size, Some(20));
6964
6965        let type_metadata = schema_catalog
6966            .objects
6967            .values()
6968            .find_map(|object| match object {
6969                CatalogObject::Type(metadata) => Some(metadata),
6970                _ => None,
6971            })
6972            .expect("type metadata present");
6973        assert_eq!(type_metadata.attributes.len(), 2);
6974        assert_eq!(type_metadata.attributes[0].position, 1);
6975        assert_eq!(type_metadata.attributes[1].position, 2);
6976        assert_eq!(type_metadata.attributes[0].data_type.name, "VARCHAR2");
6977        assert_eq!(type_metadata.attributes[0].data_type.length, Some(200));
6978
6979        let public_grant = schema_catalog
6980            .grants
6981            .iter()
6982            .find(|grant| matches!(grant.grantee, crate::Grantee::Public))
6983            .expect("public grant present");
6984        assert!(matches!(
6985            public_grant.privilege,
6986            crate::GrantPrivilege::Select
6987        ));
6988        // REPORTING_ROLE is not in the ALL_USERS fixture, so the UPDATE
6989        // grant to it must classify as a role grant (it was previously,
6990        // and incorrectly, recorded as a direct user grant — oracle-qm3q.2).
6991        let role_grant = schema_catalog
6992            .grants
6993            .iter()
6994            .find(|grant| matches!(grant.grantee, crate::Grantee::Role(_)))
6995            .expect("role grant present");
6996        let crate::Grantee::Role(role) = &role_grant.grantee else {
6997            unreachable!("matched Grantee::Role above");
6998        };
6999        assert_eq!(
7000            snapshot.interner.resolve(role.symbol()),
7001            Some("REPORTING_ROLE")
7002        );
7003        assert!(matches!(
7004            role_grant.privilege,
7005            crate::GrantPrivilege::Update
7006        ));
7007        assert!(role_grant.grantable);
7008        // And no grantee is misclassified as a direct user in this fixture.
7009        assert!(
7010            !schema_catalog
7011                .grants
7012                .iter()
7013                .any(|grant| matches!(grant.grantee, crate::Grantee::User(_)))
7014        );
7015
7016        // PLSQL-CAT-NEW-1 / oracle-rr4y: ALL_DB_LINKS rows lower into
7017        // the owning schema's `db_links` list. The fixture seeds one
7018        // private link on BILLING and one public link on the synthetic
7019        // PUBLIC schema — both must surface with correct `public_link`
7020        // classification and the raw HOST string preserved.
7021        let private_link = schema_catalog
7022            .db_links
7023            .iter()
7024            .find(|link| link.name.eq("REPORTING_LINK"))
7025            .expect("private db link present on BILLING schema");
7026        assert!(!private_link.public_link);
7027        assert_eq!(private_link.host.as_deref(), Some("reporting-db.internal"));
7028
7029        // Locate the synthetic PUBLIC schema by resolving each interned
7030        // schema name back through the interner — the loader materialized
7031        // it on demand when a `PUBLIC`-owned link was applied, but no
7032        // ergonomic lookup-by-text exists on SymbolInterner yet.
7033        let public_link = snapshot
7034            .schemas
7035            .iter()
7036            .find_map(|(schema_id, schema_catalog)| {
7037                let label = snapshot.interner.resolve(schema_id.symbol())?;
7038                if label.eq_ignore_ascii_case("PUBLIC") {
7039                    schema_catalog
7040                        .db_links
7041                        .iter()
7042                        .find(|link| link.name.eq("WORLDWIDE_LINK"))
7043                } else {
7044                    None
7045                }
7046            })
7047            .expect("public db link present on PUBLIC synthetic schema");
7048        assert!(public_link.public_link);
7049        assert_eq!(
7050            public_link.host.as_deref(),
7051            Some("//world.example.com/PROD")
7052        );
7053
7054        // PLSQL-CAT-NEW-2 / oracle-c0gg: ALL_POLICIES row lands in
7055        // SchemaCatalog::vpd_policies with SEL/INS/UPD/DEL/ENABLE
7056        // correctly decoded from the Y/N/YES/NO mix in dictionary text.
7057        assert_eq!(schema_catalog.vpd_policies.len(), 1);
7058        let policy = &schema_catalog.vpd_policies[0];
7059        assert_eq!(policy.policy_name, "BILLING_TENANT_ISOLATION");
7060        assert_eq!(policy.function_name, "TENANT_PREDICATE");
7061        assert_eq!(policy.function_package.as_deref(), Some("RLS_PKG"));
7062        assert!(policy.policy_group.is_none());
7063        assert!(policy.on_select);
7064        assert!(!policy.on_insert);
7065        assert!(!policy.on_update);
7066        assert!(!policy.on_delete);
7067        assert!(policy.enabled);
7068
7069        // PLSQL-CAT-NEW-3 / oracle-fmro: ALL_EDITIONS rows land in
7070        // CatalogSnapshot::editions; ALL_EDITIONING_VIEWS rows land in
7071        // SchemaCatalog::editioning_views.
7072        assert_eq!(snapshot.editions.len(), 2);
7073        let root = snapshot
7074            .editions
7075            .iter()
7076            .find(|e| e.edition_name.eq("ORA$BASE"))
7077            .expect("root edition present");
7078        assert!(root.parent_edition_name.is_none());
7079        assert!(root.usable);
7080        let child = snapshot
7081            .editions
7082            .iter()
7083            .find(|e| e.edition_name.eq("PATCH_2026_05"))
7084            .expect("child edition present");
7085        assert_eq!(child.parent_edition_name.as_deref(), Some("ORA$BASE"));
7086        assert_eq!(schema_catalog.editioning_views.len(), 1);
7087        let ev = &schema_catalog.editioning_views[0];
7088        let view_label = snapshot.interner.resolve(ev.view_name.symbol()).unwrap();
7089        let table_label = snapshot.interner.resolve(ev.table_name.symbol()).unwrap();
7090        assert_eq!(view_label, "INVOICES_E1");
7091        assert_eq!(table_label, "INVOICES");
7092
7093        // PLSQL-CAT-NEW-5 / oracle-grs0: ALL_TAB_COMMENTS row reaches
7094        // SchemaCatalog::table_comments with TABLE_TYPE + COMMENTS
7095        // preserved verbatim.
7096        assert_eq!(schema_catalog.table_comments.len(), 1);
7097        let table_comment = &schema_catalog.table_comments[0];
7098        assert_eq!(table_comment.table_type, "TABLE");
7099        assert_eq!(
7100            table_comment.comments,
7101            "Customer invoice header rows; one per invoice"
7102        );
7103        // ALL_COL_COMMENTS row lands in column_comments with the
7104        // interned ColumnName.
7105        assert_eq!(schema_catalog.column_comments.len(), 1);
7106        assert_eq!(
7107            schema_catalog.column_comments[0].comments,
7108            "Primary key; surrogate, allocated from INVOICES_SEQ"
7109        );
7110    }
7111
7112    #[test]
7113    fn load_snapshot_from_connection_requires_current_schema_when_requested() {
7114        let mut connection_info = StaticConnection::default().connection_info;
7115        connection_info.current_schema = None;
7116        let connection = StaticConnection {
7117            connection_info,
7118            ..StaticConnection::default()
7119        };
7120
7121        let error = load_snapshot_for_test(&connection, &CatalogLoadRequest::default());
7122
7123        assert!(matches!(error, Err(CatalogError::CurrentSchemaUnavailable)));
7124    }
7125
7126    #[test]
7127    fn oracle_connection_default_helpers_enforce_row_cardinality() {
7128        let mut row = OracleRow::default();
7129        row.insert(
7130            "schema_name",
7131            "VARCHAR2(128)",
7132            Some(String::from("billing")),
7133        );
7134
7135        let single = StaticConnection {
7136            rows: vec![row.clone()],
7137            row_count: 1,
7138            ..StaticConnection::default()
7139        };
7140        let multiple = StaticConnection {
7141            rows: vec![row.clone(), row],
7142            row_count: 2,
7143            ..StaticConnection::default()
7144        };
7145
7146        let single_result = run_catalog_future(async {
7147            let cx = test_cx();
7148            single.query_one_row(&cx, "select * from dual", &[]).await
7149        });
7150        assert!(single_result.is_ok());
7151        let multiple_result = run_catalog_future(async {
7152            let cx = test_cx();
7153            multiple
7154                .query_optional_row(&cx, "select * from dual", &[])
7155                .await
7156        });
7157        assert!(matches!(
7158            multiple_result,
7159            Err(CatalogError::UnexpectedRowCount { expected, actual })
7160                if expected.eq("0 or 1") && actual.eq(&2)
7161        ));
7162    }
7163
7164    #[test]
7165    fn oracle_connect_options_capture_session_overrides() {
7166        let options = OracleConnectOptions::new("scott", "tiger", "//localhost/XE")
7167            .with_current_schema("billing")
7168            .with_module("plsql-intelligence")
7169            .with_action("catalog-extract")
7170            .with_client_info("tests")
7171            .with_client_identifier("unit");
7172
7173        assert_eq!(options.current_schema.as_deref(), Some("billing"));
7174        assert_eq!(options.module.as_deref(), Some("plsql-intelligence"));
7175        assert_eq!(options.action.as_deref(), Some("catalog-extract"));
7176        assert_eq!(options.client_info.as_deref(), Some("tests"));
7177        assert_eq!(options.client_identifier.as_deref(), Some("unit"));
7178    }
7179
7180    #[test]
7181    fn catalog_snapshot_initializes_with_empty_schema_map() {
7182        let snapshot = CatalogSnapshot::new(
7183            AnalysisProfile::default(),
7184            CatalogCapabilities::default(),
7185            CatalogSource {
7186                kind: CatalogSourceKind::SyntheticTestCatalog,
7187                path: None,
7188                description: Some(String::from("fixture")),
7189            },
7190            DateTime::<Utc>::UNIX_EPOCH,
7191        );
7192
7193        assert!(snapshot.schemas.is_empty());
7194        assert!(snapshot.interner.is_empty());
7195        assert_eq!(snapshot.generated_at, DateTime::<Utc>::UNIX_EPOCH);
7196        assert_eq!(
7197            snapshot.source.kind,
7198            CatalogSourceKind::SyntheticTestCatalog
7199        );
7200    }
7201
7202    #[test]
7203    fn schema_catalog_can_hold_structural_lookup_maps() {
7204        let mut schema_catalog = SchemaCatalog::default();
7205        let object_name = ObjectName::from(SymbolId::new(2));
7206        let column_name = ColumnName::from(SymbolId::new(3));
7207        let owner = SchemaName::from(SymbolId::new(1));
7208
7209        let table = TableMetadata {
7210            common: ObjectCommon {
7211                owner,
7212                name: object_name,
7213                object_type: ObjectType::Table,
7214                status: ObjectStatus::Valid,
7215                source_hash: Some(Hash::new("abc123")),
7216                ..ObjectCommon::default()
7217            },
7218            columns: HashMap::from([(
7219                column_name,
7220                crate::ColumnMetadata {
7221                    name: column_name,
7222                    position: 1,
7223                    data_type: DataTypeRef {
7224                        name: String::from("NUMBER"),
7225                        precision: Some(10),
7226                        ..DataTypeRef::default()
7227                    },
7228                    nullable: false,
7229                    ..crate::ColumnMetadata::default()
7230                },
7231            )]),
7232            ..TableMetadata::default()
7233        };
7234
7235        schema_catalog
7236            .objects
7237            .insert(object_name, CatalogObject::Table(table));
7238        schema_catalog.synonyms.insert(
7239            SynonymName::from(SymbolId::new(4)),
7240            SynonymTarget {
7241                target_owner: Some(owner),
7242                target_name: object_name,
7243                public_synonym: false,
7244                ..SynonymTarget::default()
7245            },
7246        );
7247
7248        assert_eq!(schema_catalog.objects.len(), 1);
7249        assert_eq!(schema_catalog.synonyms.len(), 1);
7250    }
7251
7252    #[test]
7253    fn package_and_plscope_models_capture_signature_state() {
7254        let owner = SchemaName::from(SymbolId::new(10));
7255        let package_name = ObjectName::from(SymbolId::new(11));
7256        let procedure_name = ObjectName::from(SymbolId::new(12));
7257        let member_name = MemberName::from(SymbolId::new(13));
7258
7259        let package = PackageMetadata {
7260            common: ObjectCommon {
7261                owner,
7262                name: package_name,
7263                object_type: ObjectType::Package,
7264                status: ObjectStatus::Valid,
7265                ..ObjectCommon::default()
7266            },
7267            procedures: vec![RoutineSignature {
7268                routine_name: procedure_name,
7269                arguments: vec![crate::ArgumentMetadata {
7270                    position: 1,
7271                    name: Some(member_name),
7272                    data_type: DataTypeRef {
7273                        name: String::from("VARCHAR2"),
7274                        length: Some(30),
7275                        ..DataTypeRef::default()
7276                    },
7277                    ..crate::ArgumentMetadata::default()
7278                }],
7279                accessible_by: vec![AccessibleByTarget {
7280                    owner: Some(owner),
7281                    object_name: package_name,
7282                }],
7283                ..RoutineSignature::default()
7284            }],
7285            ..PackageMetadata::default()
7286        };
7287
7288        let plscope = PlScopeSnapshot {
7289            availability: PlScopeAvailability::IdentifiersAndStatements,
7290            identifiers: vec![CompilerIdentifier {
7291                owner,
7292                object_name: package_name,
7293                identifier_name: member_name,
7294                identifier_type: String::from("FORMAL IN"),
7295                usage: String::from("DECLARATION"),
7296                line: 4,
7297                column: 12,
7298            }],
7299            ..PlScopeSnapshot::default()
7300        };
7301
7302        assert_eq!(package.procedures.len(), 1);
7303        assert_eq!(package.procedures[0].accessible_by.len(), 1);
7304        assert_eq!(
7305            plscope.availability,
7306            PlScopeAvailability::IdentifiersAndStatements
7307        );
7308        assert_eq!(plscope.identifiers[0].line, 4);
7309        assert_eq!(ConstraintType::ForeignKey, ConstraintType::ForeignKey);
7310        assert_eq!(TypeFinality::Unknown, TypeFinality::default());
7311        assert_eq!(TypeInstantiable::Unknown, TypeInstantiable::default());
7312        assert_eq!(TriggerName::from(SymbolId::new(14)).symbol().get(), 14);
7313    }
7314
7315    #[test]
7316    fn catalog_snapshot_round_trips_through_versioned_json_document() {
7317        let tempdir = tempdir();
7318        assert!(tempdir.is_ok());
7319        let tempdir = if let Ok(tempdir) = tempdir {
7320            tempdir
7321        } else {
7322            return;
7323        };
7324
7325        let mut snapshot = CatalogSnapshot::new(
7326            AnalysisProfile::default(),
7327            CatalogCapabilities::default(),
7328            CatalogSource {
7329                kind: CatalogSourceKind::JsonSnapshot,
7330                path: None,
7331                description: Some(String::from("roundtrip")),
7332            },
7333            DateTime::<Utc>::UNIX_EPOCH,
7334        );
7335        let billing = snapshot.intern_schema_name("billing");
7336        let claims = snapshot.intern_object_name("claims");
7337        assert!(billing.is_some());
7338        assert!(claims.is_some());
7339
7340        let path = tempdir.path().join("snapshot.json");
7341        let exported = export_snapshot_to_json(&snapshot, &path);
7342        assert!(exported.is_ok());
7343
7344        let loaded = load_snapshot_from_json(&path);
7345        assert!(loaded.is_ok());
7346        assert_eq!(loaded.ok(), Some(snapshot.clone()));
7347
7348        let rendered = std::fs::read_to_string(path);
7349        assert!(rendered.is_ok());
7350        let rendered = if let Ok(rendered) = rendered {
7351            rendered
7352        } else {
7353            return;
7354        };
7355        let document = serde_json::from_str::<CatalogSnapshotDocument>(&rendered);
7356        assert!(document.is_ok());
7357        let document = if let Ok(document) = document {
7358            document
7359        } else {
7360            return;
7361        };
7362
7363        assert!(document.schema_id.as_str().eq(CATALOG_SNAPSHOT_SCHEMA_ID));
7364        assert!(matches!(
7365            document
7366                .schema_version
7367                .cmp(&CATALOG_SNAPSHOT_SCHEMA_VERSION),
7368            std::cmp::Ordering::Equal
7369        ));
7370        assert_eq!(
7371            document
7372                .snapshot
7373                .interner
7374                .resolve(billing.unwrap_or_default().symbol()),
7375            Some("billing")
7376        );
7377        assert_eq!(
7378            document
7379                .snapshot
7380                .interner
7381                .resolve(claims.unwrap_or_default().symbol()),
7382            Some("claims")
7383        );
7384    }
7385
7386    #[test]
7387    fn load_from_dbms_metadata_dir_classifies_create_statements() {
7388        let dir = tempdir().unwrap();
7389        let root = dir.path();
7390
7391        // Write some .sql files
7392        std::fs::write(
7393            root.join("customers.sql"),
7394            "CREATE TABLE customers (id NUMBER, name VARCHAR2(100));",
7395        )
7396        .unwrap();
7397        std::fs::write(
7398            root.join("billing_api.sql"),
7399            "CREATE OR REPLACE PACKAGE billing_api AS\n  PROCEDURE charge(p_id NUMBER);\nEND;",
7400        )
7401        .unwrap();
7402        std::fs::write(
7403            root.join("invoice_seq.sql"),
7404            "CREATE SEQUENCE invoice_seq START WITH 1 INCREMENT BY 1;",
7405        )
7406        .unwrap();
7407        std::fs::write(root.join("skip.txt"), "not a sql file").unwrap();
7408
7409        let snapshot = load_from_dbms_metadata_dir(root).unwrap();
7410        assert_eq!(snapshot.source.kind, CatalogSourceKind::DbmsMetadataFiles);
7411
7412        // Should have found objects in a default schema
7413        let total_objects: usize = snapshot.schemas.values().map(|s| s.objects.len()).sum();
7414        assert!(
7415            total_objects >= 2,
7416            "expected at least 2 objects, got {}",
7417            total_objects
7418        );
7419    }
7420
7421    #[test]
7422    fn load_from_dbms_metadata_dir_returns_error_for_nonexistent_dir() {
7423        let result = load_from_dbms_metadata_dir(std::path::Path::new("/nonexistent/path"));
7424        assert!(result.is_err());
7425    }
7426
7427    #[test]
7428    fn load_from_dbms_metadata_dir_handles_empty_dir() {
7429        let dir = tempdir().unwrap();
7430        let snapshot = load_from_dbms_metadata_dir(dir.path()).unwrap();
7431        assert!(
7432            snapshot.schemas.is_empty() || snapshot.schemas.values().all(|s| s.objects.is_empty())
7433        );
7434    }
7435
7436    /// DDL with a qualified `OWNER.OBJECT` prefix must be filed under the
7437    /// real owner schema, never collapsed to a single shared bucket. A
7438    /// multi-schema DBMS_METADATA dump that lands under one schema would
7439    /// silently lose cross-schema topology.
7440    #[test]
7441    fn load_from_dbms_metadata_dir_records_real_schema_owner() {
7442        let dir = tempdir().unwrap();
7443        let root = dir.path();
7444        std::fs::write(
7445            root.join("hr_employees.sql"),
7446            "CREATE TABLE hr.employees (id NUMBER PRIMARY KEY);",
7447        )
7448        .unwrap();
7449        std::fs::write(
7450            root.join("billing_invoices.sql"),
7451            "CREATE TABLE billing.invoices (id NUMBER, amount NUMBER(12,2));",
7452        )
7453        .unwrap();
7454        let snapshot = load_from_dbms_metadata_dir(root).unwrap();
7455
7456        // Two distinct owner schemas must be present — not collapsed.
7457        let schema_names: std::collections::HashSet<String> = snapshot
7458            .schemas
7459            .keys()
7460            .filter_map(|s| snapshot.interner.resolve(s.symbol()).map(str::to_string))
7461            .collect();
7462        assert!(
7463            schema_names.contains("HR"),
7464            "HR schema bucket must exist; got {schema_names:?}"
7465        );
7466        assert!(
7467            schema_names.contains("BILLING"),
7468            "BILLING schema bucket must exist; got {schema_names:?}"
7469        );
7470
7471        // Each schema bucket holds exactly its own object — never the
7472        // other's.
7473        for (schema, bucket) in &snapshot.schemas {
7474            let name = snapshot.interner.resolve(schema.symbol()).unwrap();
7475            assert_eq!(
7476                bucket.objects.len(),
7477                1,
7478                "{name} bucket must hold exactly one object"
7479            );
7480        }
7481    }
7482
7483    /// Unqualified CREATE statements (no owner prefix) must land in a
7484    /// stable named schema (e.g. `PUBLIC`) interned through the regular
7485    /// interner — never `SymbolId::new(0)` which collides with whatever
7486    /// the first interner entry happens to be.
7487    #[test]
7488    fn load_from_dbms_metadata_dir_uses_named_default_schema_for_unqualified_ddl() {
7489        let dir = tempdir().unwrap();
7490        let root = dir.path();
7491        std::fs::write(
7492            root.join("customers.sql"),
7493            "CREATE TABLE customers (id NUMBER, name VARCHAR2(100));",
7494        )
7495        .unwrap();
7496        let snapshot = load_from_dbms_metadata_dir(root).unwrap();
7497
7498        let schema_names: std::collections::HashSet<String> = snapshot
7499            .schemas
7500            .keys()
7501            .filter_map(|s| snapshot.interner.resolve(s.symbol()).map(str::to_string))
7502            .collect();
7503        assert!(
7504            schema_names.contains("PUBLIC"),
7505            "default schema bucket (PUBLIC) must exist for unqualified DDL; got {schema_names:?}"
7506        );
7507    }
7508
7509    /// The classifier must actually record the raw DDL text on the
7510    /// produced `CatalogObject` — the original docstring promised this
7511    /// and downstream consumers (doc generation, lineage) rely on it.
7512    #[test]
7513    fn load_from_dbms_metadata_dir_records_raw_ddl_text_on_object() {
7514        let dir = tempdir().unwrap();
7515        let root = dir.path();
7516        let raw = "CREATE TABLE hr.orders (id NUMBER PRIMARY KEY, total NUMBER(12,2));";
7517        std::fs::write(root.join("orders.sql"), raw).unwrap();
7518        let snapshot = load_from_dbms_metadata_dir(root).unwrap();
7519
7520        let bucket = snapshot
7521            .schemas
7522            .values()
7523            .find(|b| !b.objects.is_empty())
7524            .expect("at least one schema bucket with an object");
7525        let obj = bucket.objects.values().next().unwrap();
7526        let common = match obj {
7527            CatalogObject::Table(t) => Some(&t.common),
7528            _ => None,
7529        }
7530        .expect("expected Table");
7531        let ddl = common
7532            .ddl
7533            .as_ref()
7534            .expect("CatalogObject must carry its raw DDL text");
7535        assert_eq!(ddl.ddl_text, raw, "ddl_text must round-trip the source DDL");
7536    }
7537
7538    #[test]
7539    fn load_snapshot_populates_object_metadata_and_dependency_rows() {
7540        let object_rows = vec![
7541            oracle_row(&[
7542                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
7543                ("OBJECT_NAME", "VARCHAR2(128)", Some("BILLING_PKG")),
7544                ("OBJECT_TYPE", "VARCHAR2(30)", Some("PACKAGE")),
7545                ("STATUS", "VARCHAR2(7)", Some("INVALID")),
7546                (
7547                    "LAST_DDL_TIME_ISO",
7548                    "VARCHAR2(19)",
7549                    Some("2026-05-01T13:14:15"),
7550                ),
7551                ("EDITIONABLE", "VARCHAR2(1)", Some("Y")),
7552                ("EDITION_NAME", "VARCHAR2(128)", Some("ORA$BASE")),
7553            ]),
7554            oracle_row(&[
7555                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
7556                ("OBJECT_NAME", "VARCHAR2(128)", Some("CUSTOMERS")),
7557                ("OBJECT_TYPE", "VARCHAR2(30)", Some("TABLE")),
7558                ("STATUS", "VARCHAR2(7)", Some("VALID")),
7559                (
7560                    "LAST_DDL_TIME_ISO",
7561                    "VARCHAR2(19)",
7562                    Some("2026-04-22T08:30:00"),
7563                ),
7564                ("EDITIONABLE", "VARCHAR2(1)", None),
7565                ("EDITION_NAME", "VARCHAR2(128)", None),
7566            ]),
7567        ];
7568
7569        let dependency_rows = vec![
7570            oracle_row(&[
7571                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
7572                ("NAME", "VARCHAR2(128)", Some("BILLING_PKG")),
7573                ("TYPE", "VARCHAR2(30)", Some("PACKAGE")),
7574                ("REFERENCED_OWNER", "VARCHAR2(128)", Some("BILLING")),
7575                ("REFERENCED_NAME", "VARCHAR2(128)", Some("CUSTOMERS")),
7576                ("REFERENCED_TYPE", "VARCHAR2(30)", Some("TABLE")),
7577                ("DEPENDENCY_TYPE", "VARCHAR2(4)", Some("HARD")),
7578            ]),
7579            oracle_row(&[
7580                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
7581                ("NAME", "VARCHAR2(128)", Some("BILLING_PKG")),
7582                ("TYPE", "VARCHAR2(30)", Some("PACKAGE")),
7583                ("REFERENCED_OWNER", "VARCHAR2(128)", Some("SYS")),
7584                ("REFERENCED_NAME", "VARCHAR2(128)", Some("DBMS_OUTPUT")),
7585                ("REFERENCED_TYPE", "VARCHAR2(30)", Some("PACKAGE")),
7586                ("DEPENDENCY_TYPE", "VARCHAR2(4)", Some("REF")),
7587            ]),
7588        ];
7589
7590        let billing_only = vec![OracleBind::from("BILLING")];
7591        let mut expected_queries = capability_probe_expectations();
7592        // No grants are asserted here, but live extraction still issues the
7593        // ALL_USERS probe before ALL_TAB_PRIVS.
7594        expected_queries.push(all_users_expectation(&["BILLING"]));
7595        expected_queries.extend(vec![
7596            QueryExpectation {
7597                sql_contains: String::from("from all_objects"),
7598                params: billing_only.clone(),
7599                rows: object_rows,
7600            },
7601            QueryExpectation {
7602                sql_contains: String::from("from all_tab_cols"),
7603                params: billing_only.clone(),
7604                rows: vec![],
7605            },
7606            QueryExpectation {
7607                sql_contains: String::from("from all_constraints c"),
7608                params: billing_only.clone(),
7609                rows: vec![],
7610            },
7611            QueryExpectation {
7612                sql_contains: String::from("from all_indexes i"),
7613                params: billing_only.clone(),
7614                rows: vec![],
7615            },
7616            QueryExpectation {
7617                sql_contains: String::from("from all_triggers"),
7618                params: billing_only.clone(),
7619                rows: vec![],
7620            },
7621            QueryExpectation {
7622                sql_contains: String::from("from all_synonyms"),
7623                params: billing_only.clone(),
7624                rows: vec![],
7625            },
7626            QueryExpectation {
7627                sql_contains: String::from("from all_procedures"),
7628                params: billing_only.clone(),
7629                rows: vec![],
7630            },
7631            QueryExpectation {
7632                sql_contains: String::from("from all_arguments"),
7633                params: billing_only.clone(),
7634                rows: vec![],
7635            },
7636            QueryExpectation {
7637                sql_contains: String::from("from all_views"),
7638                params: billing_only.clone(),
7639                rows: vec![],
7640            },
7641            QueryExpectation {
7642                sql_contains: String::from("from all_mviews"),
7643                params: billing_only.clone(),
7644                rows: vec![],
7645            },
7646            QueryExpectation {
7647                sql_contains: String::from("from all_sequences"),
7648                params: billing_only.clone(),
7649                rows: vec![],
7650            },
7651            QueryExpectation {
7652                sql_contains: String::from("from all_type_attrs"),
7653                params: billing_only.clone(),
7654                rows: vec![],
7655            },
7656            QueryExpectation {
7657                sql_contains: String::from("from all_tab_privs"),
7658                params: billing_only.clone(),
7659                rows: vec![],
7660            },
7661            QueryExpectation {
7662                sql_contains: String::from("from all_db_links"),
7663                params: billing_only.clone(),
7664                rows: vec![],
7665            },
7666            QueryExpectation {
7667                sql_contains: String::from("from all_tab_comments"),
7668                params: billing_only.clone(),
7669                rows: vec![],
7670            },
7671            QueryExpectation {
7672                sql_contains: String::from("from all_col_comments"),
7673                params: billing_only.clone(),
7674                rows: vec![],
7675            },
7676            QueryExpectation {
7677                sql_contains: String::from("from all_editions"),
7678                params: vec![],
7679                rows: vec![],
7680            },
7681            QueryExpectation {
7682                sql_contains: String::from("from all_editioning_views"),
7683                params: billing_only.clone(),
7684                rows: vec![],
7685            },
7686            QueryExpectation {
7687                sql_contains: String::from("from all_policies"),
7688                params: billing_only.clone(),
7689                rows: vec![],
7690            },
7691            QueryExpectation {
7692                sql_contains: String::from("from all_dependencies"),
7693                params: billing_only,
7694                rows: dependency_rows,
7695            },
7696        ]);
7697        let connection = StaticConnection {
7698            expected_queries,
7699            ..StaticConnection::default()
7700        };
7701
7702        let snapshot = load_snapshot_for_test(&connection, &CatalogLoadRequest::default()).unwrap();
7703        let schema = snapshot
7704            .profile
7705            .current_schema
7706            .expect("current schema interned");
7707        let schema_catalog = snapshot.schemas.get(&schema).expect("billing schema");
7708
7709        let billing_pkg = schema_catalog
7710            .objects
7711            .values()
7712            .find_map(|object| match object {
7713                CatalogObject::Package(metadata) => Some(metadata),
7714                _ => None,
7715            })
7716            .expect("billing pkg present");
7717        assert_eq!(billing_pkg.common.status, ObjectStatus::Invalid);
7718        assert_eq!(billing_pkg.common.editionable, Some(true));
7719        assert!(billing_pkg.common.edition_name.is_some());
7720        let last_ddl = billing_pkg
7721            .common
7722            .last_ddl_time
7723            .expect("last_ddl_time populated");
7724        // The fixture date is 2026-05-01 13:14:15 UTC.
7725        assert_eq!(last_ddl.timestamp(), 1_777_641_255);
7726
7727        let customers_table = schema_catalog
7728            .objects
7729            .values()
7730            .find_map(|object| match object {
7731                CatalogObject::Table(metadata) => Some(metadata),
7732                _ => None,
7733            })
7734            .expect("customers table");
7735        assert!(customers_table.common.editionable.is_none());
7736        assert!(customers_table.common.edition_name.is_none());
7737
7738        assert_eq!(schema_catalog.dependencies.len(), 2);
7739        let pkg_to_customers = schema_catalog
7740            .dependencies
7741            .iter()
7742            .find(|d| matches!(d.dependency_kind, CatalogDependencyKind::Hard))
7743            .expect("hard dependency");
7744        assert!(matches!(pkg_to_customers.object_type, ObjectType::Package));
7745        assert!(matches!(
7746            pkg_to_customers.referenced_type,
7747            Some(ObjectType::Table)
7748        ));
7749        let pkg_to_sys = schema_catalog
7750            .dependencies
7751            .iter()
7752            .find(|d| matches!(d.dependency_kind, CatalogDependencyKind::Reference))
7753            .expect("ref dependency");
7754        assert!(matches!(
7755            pkg_to_sys.referenced_type,
7756            Some(ObjectType::Package)
7757        ));
7758        assert_eq!(
7759            pkg_to_sys
7760                .referenced_owner
7761                .and_then(|owner| snapshot.interner.resolve(owner.symbol())),
7762            Some("SYS")
7763        );
7764    }
7765
7766    #[test]
7767    fn doctor_report_counts_objects_columns_and_indexes() {
7768        let connection = StaticConnection::default();
7769        let snapshot = load_snapshot_for_test(&connection, &CatalogLoadRequest::default()).unwrap();
7770        let mut snapshot = snapshot;
7771        let billing = snapshot.intern_schema_name("BILLING").expect("schema name");
7772        let invoices = snapshot
7773            .intern_object_name("INVOICES")
7774            .expect("object name");
7775        let invoice_view = snapshot
7776            .intern_object_name("INVOICE_VIEW")
7777            .expect("object name");
7778        let billing_seq = snapshot
7779            .intern_object_name("BILLING_SEQ")
7780            .expect("sequence name");
7781
7782        let invoice_id = snapshot
7783            .intern_column_name("INVOICE_ID")
7784            .expect("column name");
7785        let table_metadata = crate::TableMetadata {
7786            common: ObjectCommon {
7787                owner: billing,
7788                name: invoices,
7789                object_type: ObjectType::Table,
7790                status: ObjectStatus::Valid,
7791                ..ObjectCommon::default()
7792            },
7793            columns: HashMap::from([(
7794                invoice_id,
7795                crate::ColumnMetadata {
7796                    name: invoice_id,
7797                    position: 1,
7798                    ..crate::ColumnMetadata::default()
7799                },
7800            )]),
7801            ..crate::TableMetadata::default()
7802        };
7803
7804        let view_metadata = crate::ViewMetadata {
7805            common: ObjectCommon {
7806                owner: billing,
7807                name: invoice_view,
7808                object_type: ObjectType::View,
7809                status: ObjectStatus::Invalid,
7810                ..ObjectCommon::default()
7811            },
7812            ..crate::ViewMetadata::default()
7813        };
7814
7815        let sequence_metadata = crate::SequenceMetadata {
7816            common: ObjectCommon {
7817                owner: billing,
7818                name: billing_seq,
7819                object_type: ObjectType::Sequence,
7820                status: ObjectStatus::Valid,
7821                ..ObjectCommon::default()
7822            },
7823            ..crate::SequenceMetadata::default()
7824        };
7825
7826        let schema_catalog = snapshot.schemas.entry(billing).or_default();
7827        schema_catalog
7828            .objects
7829            .insert(invoices, CatalogObject::Table(table_metadata));
7830        schema_catalog
7831            .objects
7832            .insert(invoice_view, CatalogObject::View(view_metadata));
7833        schema_catalog
7834            .objects
7835            .insert(billing_seq, CatalogObject::Sequence(sequence_metadata));
7836
7837        let report = snapshot.doctor_report();
7838        assert_eq!(report.totals.objects_total, 3);
7839        assert_eq!(report.totals.columns_total, 1);
7840        let table_tile = report
7841            .object_counts
7842            .iter()
7843            .find(|tile| matches!(tile.object_type, ObjectType::Table))
7844            .expect("table tile");
7845        assert_eq!(table_tile.total, 1);
7846        assert_eq!(table_tile.valid, 1);
7847        let view_tile = report
7848            .object_counts
7849            .iter()
7850            .find(|tile| matches!(tile.object_type, ObjectType::View))
7851            .expect("view tile");
7852        assert_eq!(view_tile.invalid, 1);
7853        // Capability negotiation (PLSQL-CAT-017) probes the connection; the
7854        // StaticConnection mock returns Ok([]) for unmatched queries which
7855        // means every probe succeeds in this test path → no missing-permission
7856        // suggestions should appear.
7857        assert!(report.can_query_all_views);
7858        assert!(report.can_use_dbms_metadata);
7859        assert!(
7860            report.missing_permissions.is_empty(),
7861            "all probes succeeded on the mock so no permissions should be flagged"
7862        );
7863    }
7864
7865    #[test]
7866    fn doctor_report_suggests_grants_when_capabilities_are_missing() {
7867        let mut snapshot = CatalogSnapshot::new(
7868            plsql_core::AnalysisProfile::default(),
7869            CatalogCapabilities {
7870                can_query_all_views: true,
7871                can_query_dba_views: false,
7872                can_use_dbms_metadata: false,
7873                can_read_source: false,
7874                plscope_enabled: false,
7875                can_query_scheduler: false,
7876                can_query_roles_and_grants: false,
7877                warnings: vec![CapabilityWarning {
7878                    code: String::from("catalog-version-parse-fallback"),
7879                    message: String::from("server version did not parse"),
7880                    remediation: None,
7881                }],
7882            },
7883            CatalogSource {
7884                kind: CatalogSourceKind::LiveConnection,
7885                path: None,
7886                description: Some(String::from("live extraction via oraclemcp-db from xe")),
7887            },
7888            DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap(),
7889        );
7890        let _ = snapshot.intern_schema_name("BILLING");
7891
7892        let report = snapshot.doctor_report();
7893        assert!(matches!(
7894            report.source_kind,
7895            CatalogSourceKind::LiveConnection
7896        ));
7897        assert_eq!(report.capability_warnings.len(), 1);
7898        let view_names: Vec<&str> = report
7899            .missing_permissions
7900            .iter()
7901            .map(|m| m.view_name.as_str())
7902            .collect();
7903        assert!(view_names.iter().any(|v| v.contains("DBA_OBJECTS")));
7904        assert!(view_names.iter().any(|v| v.contains("DBMS_METADATA")));
7905        assert!(view_names.iter().any(|v| v.contains("ALL_SOURCE")));
7906        assert!(view_names.iter().any(|v| v.contains("PLSCOPE_SETTINGS")));
7907        assert!(view_names.iter().any(|v| v.contains("SCHEDULER_JOBS")));
7908        assert!(view_names.iter().any(|v| v.contains("ROLE_PRIVS")));
7909    }
7910
7911    #[test]
7912    fn load_snapshot_populates_plscope_availability_from_object_settings() {
7913        let plscope_rows = vec![
7914            oracle_row(&[
7915                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
7916                (
7917                    "PLSCOPE_SETTINGS",
7918                    "VARCHAR2(255)",
7919                    Some("IDENTIFIERS:ALL,STATEMENTS:ALL"),
7920                ),
7921            ]),
7922            oracle_row(&[
7923                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
7924                (
7925                    "PLSCOPE_SETTINGS",
7926                    "VARCHAR2(255)",
7927                    Some("IDENTIFIERS:ALL,STATEMENTS:NONE"),
7928                ),
7929            ]),
7930            oracle_row(&[
7931                ("OWNER", "VARCHAR2(128)", Some("REPORTING")),
7932                (
7933                    "PLSCOPE_SETTINGS",
7934                    "VARCHAR2(255)",
7935                    Some("IDENTIFIERS:NONE,STATEMENTS:NONE"),
7936                ),
7937            ]),
7938        ];
7939
7940        let billing_only = vec![OracleBind::from("BILLING")];
7941        let mut expected_queries = capability_probe_expectations();
7942        // Live extraction issues the ALL_USERS probe before ALL_TAB_PRIVS.
7943        expected_queries.push(all_users_expectation(&["BILLING", "REPORTING"]));
7944        for fragment in [
7945            "from all_objects",
7946            "from all_tab_cols",
7947            "from all_constraints c",
7948            "from all_indexes i",
7949            "from all_triggers",
7950            "from all_synonyms",
7951            "from all_procedures",
7952            "from all_arguments",
7953            "from all_views",
7954            "from all_mviews",
7955            "from all_sequences",
7956            "from all_type_attrs",
7957            "from all_tab_privs",
7958            "from all_db_links",
7959            "from all_tab_comments",
7960            "from all_col_comments",
7961            "from all_editions",
7962            "from all_editioning_views",
7963            "from all_policies",
7964            "from all_dependencies",
7965        ] {
7966            // all_editions is database-wide — no schema-bind param.
7967            let params = if fragment.eq("from all_editions") {
7968                vec![]
7969            } else {
7970                billing_only.clone()
7971            };
7972            expected_queries.push(QueryExpectation {
7973                sql_contains: String::from(fragment),
7974                params,
7975                rows: vec![],
7976            });
7977        }
7978        expected_queries.push(QueryExpectation {
7979            sql_contains: String::from("from all_plsql_object_settings"),
7980            params: billing_only.clone(),
7981            rows: plscope_rows,
7982        });
7983        let connection = StaticConnection {
7984            expected_queries,
7985            ..StaticConnection::default()
7986        };
7987        let snapshot = load_snapshot_for_test(&connection, &CatalogLoadRequest::default()).unwrap();
7988
7989        let billing = snapshot
7990            .schemas
7991            .keys()
7992            .find(|name| {
7993                snapshot
7994                    .interner
7995                    .resolve(name.symbol())
7996                    .is_some_and(|label| label.eq("BILLING"))
7997            })
7998            .copied()
7999            .expect("billing schema");
8000        let plscope = snapshot
8001            .schemas
8002            .get(&billing)
8003            .and_then(|s| s.plscope.as_ref())
8004            .expect("billing plscope");
8005        assert!(matches!(
8006            plscope.availability,
8007            PlScopeAvailability::IdentifiersAndStatements
8008        ));
8009
8010        let reporting = snapshot
8011            .schemas
8012            .keys()
8013            .find(|name| {
8014                snapshot
8015                    .interner
8016                    .resolve(name.symbol())
8017                    .is_some_and(|label| label.eq("REPORTING"))
8018            })
8019            .copied()
8020            .expect("reporting schema");
8021        let reporting_plscope = snapshot
8022            .schemas
8023            .get(&reporting)
8024            .and_then(|s| s.plscope.as_ref())
8025            .expect("reporting plscope");
8026        // All-NONE settings → PL/Scope is wired but stale.
8027        assert!(matches!(
8028            reporting_plscope.availability,
8029            PlScopeAvailability::AvailableButStale
8030        ));
8031    }
8032
8033    #[test]
8034    fn load_snapshot_extracts_all_identifiers_into_plscope() {
8035        let identifier_rows = vec![
8036            oracle_row(&[
8037                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
8038                ("NAME", "VARCHAR2(128)", Some("CUSTOMER_ID")),
8039                ("TYPE", "VARCHAR2(30)", Some("VARIABLE")),
8040                ("USAGE", "VARCHAR2(20)", Some("DECLARATION")),
8041                ("LINE", "NUMBER(10)", Some("12")),
8042                ("COL", "NUMBER(10)", Some("5")),
8043                ("OBJECT_NAME", "VARCHAR2(128)", Some("BILLING_PKG")),
8044            ]),
8045            oracle_row(&[
8046                ("OWNER", "VARCHAR2(128)", Some("BILLING")),
8047                ("NAME", "VARCHAR2(128)", Some("CUSTOMER_ID")),
8048                ("TYPE", "VARCHAR2(30)", Some("VARIABLE")),
8049                ("USAGE", "VARCHAR2(20)", Some("REFERENCE")),
8050                ("LINE", "NUMBER(10)", Some("18")),
8051                ("COL", "NUMBER(10)", Some("21")),
8052                ("OBJECT_NAME", "VARCHAR2(128)", Some("BILLING_PKG")),
8053            ]),
8054        ];
8055
8056        let billing_only = vec![OracleBind::from("BILLING")];
8057        let mut expected_queries = capability_probe_expectations();
8058        // Live extraction issues the ALL_USERS probe before ALL_TAB_PRIVS.
8059        expected_queries.push(all_users_expectation(&["BILLING"]));
8060        for fragment in [
8061            "from all_objects",
8062            "from all_tab_cols",
8063            "from all_constraints c",
8064            "from all_indexes i",
8065            "from all_triggers",
8066            "from all_synonyms",
8067            "from all_procedures",
8068            "from all_arguments",
8069            "from all_views",
8070            "from all_mviews",
8071            "from all_sequences",
8072            "from all_type_attrs",
8073            "from all_tab_privs",
8074            "from all_db_links",
8075            "from all_tab_comments",
8076            "from all_col_comments",
8077            "from all_editions",
8078            "from all_editioning_views",
8079            "from all_policies",
8080            "from all_dependencies",
8081            "from all_plsql_object_settings",
8082        ] {
8083            let params = if fragment.eq("from all_editions") {
8084                vec![]
8085            } else {
8086                billing_only.clone()
8087            };
8088            expected_queries.push(QueryExpectation {
8089                sql_contains: String::from(fragment),
8090                params,
8091                rows: vec![],
8092            });
8093        }
8094        expected_queries.push(QueryExpectation {
8095            sql_contains: String::from("from all_identifiers"),
8096            params: billing_only.clone(),
8097            rows: identifier_rows,
8098        });
8099        let connection = StaticConnection {
8100            expected_queries,
8101            ..StaticConnection::default()
8102        };
8103        let snapshot = load_snapshot_for_test(&connection, &CatalogLoadRequest::default()).unwrap();
8104
8105        let billing = snapshot
8106            .schemas
8107            .keys()
8108            .find(|name| {
8109                snapshot
8110                    .interner
8111                    .resolve(name.symbol())
8112                    .is_some_and(|label| label.eq("BILLING"))
8113            })
8114            .copied()
8115            .expect("billing schema");
8116        let plscope = snapshot
8117            .schemas
8118            .get(&billing)
8119            .and_then(|s| s.plscope.as_ref())
8120            .expect("plscope present");
8121        assert_eq!(plscope.identifiers.len(), 2);
8122        let first = &plscope.identifiers[0];
8123        assert_eq!(first.identifier_type, "VARIABLE");
8124        assert_eq!(first.usage, "DECLARATION");
8125        assert_eq!(first.line, 12);
8126        assert_eq!(first.column, 5);
8127    }
8128
8129    #[test]
8130    fn normalize_dbms_metadata_ddl_collapses_whitespace_and_trims_slash() {
8131        use crate::normalize_dbms_metadata_ddl;
8132        let input = "  CREATE   TABLE   FOO ( ID   NUMBER )  \n/  ";
8133        let normalized = normalize_dbms_metadata_ddl(input);
8134        assert_eq!(normalized, "CREATE TABLE FOO ( ID NUMBER )");
8135    }
8136
8137    #[test]
8138    fn object_type_to_dbms_metadata_value_covers_all_known_types() {
8139        use crate::object_type_to_dbms_metadata_value;
8140        // Every object kind that DBMS_METADATA supports must map to a name.
8141        for object_type in [
8142            ObjectType::Table,
8143            ObjectType::View,
8144            ObjectType::MaterializedView,
8145            ObjectType::Sequence,
8146            ObjectType::Type,
8147            ObjectType::Package,
8148            ObjectType::Procedure,
8149            ObjectType::Function,
8150            ObjectType::Trigger,
8151            ObjectType::EditioningView,
8152            ObjectType::SchedulerJob,
8153            ObjectType::Synonym,
8154            ObjectType::Index,
8155        ] {
8156            assert!(object_type_to_dbms_metadata_value(object_type).is_some());
8157        }
8158        // Constraint + Unknown are not directly fetchable via DBMS_METADATA.
8159        assert!(object_type_to_dbms_metadata_value(ObjectType::Constraint).is_none());
8160        assert!(object_type_to_dbms_metadata_value(ObjectType::Unknown).is_none());
8161    }
8162
8163    #[test]
8164    fn populate_dbms_metadata_ddl_records_warnings_on_fetch_failure() {
8165        use crate::CatalogCapabilities;
8166
8167        // Build a tiny snapshot with one TABLE object, then run populate
8168        // against a mock that returns Err on every query. We expect the
8169        // CapabilityWarning trail to grow by one but the snapshot to remain
8170        // intact.
8171        let mut snapshot = CatalogSnapshot::new(
8172            plsql_core::AnalysisProfile::default(),
8173            CatalogCapabilities {
8174                can_use_dbms_metadata: true,
8175                ..CatalogCapabilities::default()
8176            },
8177            CatalogSource {
8178                kind: CatalogSourceKind::LiveConnection,
8179                path: None,
8180                description: Some(String::from("test")),
8181            },
8182            DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap(),
8183        );
8184        let billing = snapshot.intern_schema_name("BILLING").expect("schema");
8185        let invoices = snapshot.intern_object_name("INVOICES").expect("object");
8186        let table = crate::TableMetadata {
8187            common: ObjectCommon {
8188                owner: billing,
8189                name: invoices,
8190                object_type: ObjectType::Table,
8191                status: ObjectStatus::Valid,
8192                ..ObjectCommon::default()
8193            },
8194            ..crate::TableMetadata::default()
8195        };
8196        snapshot
8197            .schemas
8198            .entry(billing)
8199            .or_default()
8200            .objects
8201            .insert(invoices, CatalogObject::Table(table));
8202
8203        // Force the mock to fail by requesting params that won't match.
8204        let failing_connection = StaticConnection {
8205            expected_queries: vec![QueryExpectation {
8206                sql_contains: String::from("dbms_metadata.get_ddl"),
8207                params: vec![OracleBind::from("UNREACHABLE_SENTINEL")],
8208                rows: vec![],
8209            }],
8210            ..StaticConnection::default()
8211        };
8212
8213        populate_dbms_metadata_ddl_for_test(&failing_connection, &mut snapshot).unwrap();
8214
8215        assert!(
8216            snapshot
8217                .capabilities
8218                .warnings
8219                .iter()
8220                .any(|w| w.code.eq("dbms-metadata-fetch-failed"))
8221        );
8222
8223        // Capability disabled → populate is a no-op (no extra warning).
8224        snapshot.capabilities.can_use_dbms_metadata = false;
8225        let baseline_warnings = snapshot.capabilities.warnings.len();
8226        populate_dbms_metadata_ddl_for_test(&failing_connection, &mut snapshot).unwrap();
8227        assert_eq!(snapshot.capabilities.warnings.len(), baseline_warnings);
8228    }
8229
8230    #[test]
8231    fn populate_dbms_metadata_ddl_writes_ddl_field_on_success() {
8232        use crate::CatalogCapabilities;
8233
8234        let mut snapshot = CatalogSnapshot::new(
8235            plsql_core::AnalysisProfile::default(),
8236            CatalogCapabilities {
8237                can_use_dbms_metadata: true,
8238                ..CatalogCapabilities::default()
8239            },
8240            CatalogSource {
8241                kind: CatalogSourceKind::LiveConnection,
8242                path: None,
8243                description: Some(String::from("test")),
8244            },
8245            DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap(),
8246        );
8247        let billing = snapshot.intern_schema_name("BILLING").expect("schema");
8248        let invoices = snapshot.intern_object_name("INVOICES").expect("object");
8249        let table = crate::TableMetadata {
8250            common: ObjectCommon {
8251                owner: billing,
8252                name: invoices,
8253                object_type: ObjectType::Table,
8254                status: ObjectStatus::Valid,
8255                ..ObjectCommon::default()
8256            },
8257            ..crate::TableMetadata::default()
8258        };
8259        snapshot
8260            .schemas
8261            .entry(billing)
8262            .or_default()
8263            .objects
8264            .insert(invoices, CatalogObject::Table(table));
8265
8266        let connection = StaticConnection {
8267            expected_queries: vec![QueryExpectation {
8268                sql_contains: String::from("dbms_metadata.get_ddl"),
8269                params: vec![
8270                    OracleBind::from("TABLE"),
8271                    OracleBind::from("INVOICES"),
8272                    OracleBind::from("BILLING"),
8273                ],
8274                rows: vec![oracle_row(&[
8275                    (
8276                        "DDL_TEXT",
8277                        "CLOB",
8278                        Some("  CREATE TABLE BILLING.INVOICES (ID NUMBER)  \n  "),
8279                    ),
8280                    (
8281                        "XML_TEXT",
8282                        "CLOB",
8283                        Some("<TABLE_T><NAME>INVOICES</NAME></TABLE_T>"),
8284                    ),
8285                ])],
8286            }],
8287            ..StaticConnection::default()
8288        };
8289
8290        populate_dbms_metadata_ddl_for_test(&connection, &mut snapshot).unwrap();
8291
8292        let stored = snapshot
8293            .schemas
8294            .get(&billing)
8295            .and_then(|schema| schema.objects.get(&invoices));
8296        assert!(
8297            matches!(stored, Some(CatalogObject::Table(_))),
8298            "expected a Table catalog object for billing.invoices"
8299        );
8300        let Some(CatalogObject::Table(table)) = stored else {
8301            return;
8302        };
8303        let ddl = table.common.ddl.as_ref().expect("ddl populated");
8304        assert!(ddl.ddl_text.contains("CREATE TABLE BILLING.INVOICES"));
8305        assert_eq!(
8306            ddl.normalized_ddl.as_deref(),
8307            Some("CREATE TABLE BILLING.INVOICES (ID NUMBER)")
8308        );
8309        assert_eq!(
8310            ddl.xml_text.as_deref(),
8311            Some("<TABLE_T><NAME>INVOICES</NAME></TABLE_T>")
8312        );
8313    }
8314
8315    #[test]
8316    fn negotiate_capabilities_reports_failures_as_warnings() {
8317        let connection = StaticConnection {
8318            expected_queries: capability_probe_expectations()
8319                .into_iter()
8320                .map(|mut q| {
8321                    // Force every probe to "fail" by demanding a non-empty
8322                    // params slice while the negotiator passes empty params.
8323                    q.params = vec![OracleBind::from("UNREACHABLE_SENTINEL")];
8324                    q
8325                })
8326                .collect(),
8327            ..StaticConnection::default()
8328        };
8329
8330        let capabilities = negotiate_capabilities_for_test(&connection);
8331
8332        assert!(!capabilities.can_query_all_views);
8333        assert!(!capabilities.can_query_dba_views);
8334        assert!(!capabilities.can_read_source);
8335        assert!(!capabilities.can_query_scheduler);
8336        assert!(!capabilities.can_query_roles_and_grants);
8337        assert!(!capabilities.plscope_enabled);
8338        // execute() succeeds unconditionally in the mock, so DBMS_METADATA
8339        // probe is the one capability that "passes" without explicit setup.
8340        assert!(capabilities.can_use_dbms_metadata);
8341        // One warning per failed probe (six probes).
8342        assert_eq!(capabilities.warnings.len(), 6);
8343        assert!(
8344            capabilities
8345                .warnings
8346                .iter()
8347                .any(|w| w.code.eq("all-views-probe"))
8348        );
8349        assert!(
8350            capabilities
8351                .warnings
8352                .iter()
8353                .any(|w| w.code.eq("plscope-probe"))
8354        );
8355    }
8356
8357    #[test]
8358    fn negotiate_capabilities_marks_every_probe_succeeded_on_open_mock() {
8359        // Default StaticConnection has no expected_queries — falls through to
8360        // returning self.rows (empty) for every query → every probe succeeds.
8361        let connection = StaticConnection::default();
8362        let capabilities = negotiate_capabilities_for_test(&connection);
8363        assert!(capabilities.can_query_all_views);
8364        assert!(capabilities.can_query_dba_views);
8365        assert!(capabilities.can_read_source);
8366        assert!(capabilities.can_query_scheduler);
8367        assert!(capabilities.can_query_roles_and_grants);
8368        assert!(capabilities.plscope_enabled);
8369        assert!(capabilities.can_use_dbms_metadata);
8370        assert!(capabilities.warnings.is_empty());
8371    }
8372
8373    #[test]
8374    fn doctor_report_skips_grant_suggestions_for_json_snapshot_source() {
8375        let snapshot = CatalogSnapshot::new(
8376            plsql_core::AnalysisProfile::default(),
8377            CatalogCapabilities {
8378                can_query_all_views: false,
8379                can_query_dba_views: false,
8380                can_use_dbms_metadata: false,
8381                can_read_source: false,
8382                plscope_enabled: false,
8383                can_query_scheduler: false,
8384                can_query_roles_and_grants: false,
8385                warnings: vec![],
8386            },
8387            CatalogSource {
8388                kind: CatalogSourceKind::JsonSnapshot,
8389                path: Some(std::path::PathBuf::from("/tmp/snapshot.json")),
8390                description: None,
8391            },
8392            DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap(),
8393        );
8394
8395        let report = snapshot.doctor_report();
8396        assert!(matches!(
8397            report.source_kind,
8398            CatalogSourceKind::JsonSnapshot
8399        ));
8400        // Recommendations are inert for JSON snapshots — the capability bits
8401        // were already frozen at extraction time.
8402        assert!(report.missing_permissions.is_empty());
8403    }
8404
8405    // ----------------------------------------------------------------------
8406    // DDL kind classifier (header tokenizer, not body substring).
8407    //
8408    // Regression bar: the prior implementation substring-matched on the full
8409    // upper-cased DDL, so any object whose body or comments mentioned
8410    // `TABLE` was silently filed as a Table. Real public surface, real
8411    // silent data corruption.
8412    // ----------------------------------------------------------------------
8413
8414    /// Drive the per-file DDL through the public loader and return the
8415    /// single (`ObjectType`, `CatalogObject`) pair it classifies into.
8416    /// Panics on anything but exactly one classified object — every test
8417    /// below feeds exactly one DDL so the helper stays obvious.
8418    fn classify_single(ddl: &str) -> (ObjectType, CatalogObject) {
8419        let dir = tempdir().unwrap();
8420        std::fs::write(dir.path().join("obj.sql"), ddl).unwrap();
8421        let snapshot = load_from_dbms_metadata_dir(dir.path()).unwrap();
8422        let mut found: Vec<CatalogObject> = snapshot
8423            .schemas
8424            .values()
8425            .flat_map(|s| s.objects.values().cloned())
8426            .collect();
8427        assert_eq!(
8428            found.len(),
8429            1,
8430            "expected exactly one classified object for DDL: {ddl}"
8431        );
8432        let obj = found.remove(0);
8433        let common = match &obj {
8434            CatalogObject::Table(m) => &m.common,
8435            CatalogObject::View(m) => &m.common,
8436            CatalogObject::MaterializedView(m) => &m.common,
8437            CatalogObject::Sequence(m) => &m.common,
8438            CatalogObject::Type(m) => &m.common,
8439            CatalogObject::Package(m) => &m.common,
8440            CatalogObject::Procedure(m) => &m.common,
8441            CatalogObject::Function(m) => &m.common,
8442            CatalogObject::Trigger(m) => &m.common,
8443            CatalogObject::SchedulerJob(m) => &m.common,
8444            CatalogObject::EditioningView(m) => &m.common,
8445        };
8446        (common.object_type, obj)
8447    }
8448
8449    /// A VIEW whose body merely mentions the word `TABLE` must classify as
8450    /// a View, never a Table. The prior substring matcher silently filed
8451    /// such views as tables — real catalog corruption visible to every
8452    /// downstream consumer.
8453    #[test]
8454    fn classify_view_with_table_in_body_is_view_not_table() {
8455        let ddl = "CREATE OR REPLACE VIEW hr.v_emp AS SELECT * FROM hr.emp WHERE 'TABLE'='TABLE';";
8456        let (kind, obj) = classify_single(ddl);
8457        assert_eq!(
8458            kind,
8459            ObjectType::View,
8460            "VIEW with 'TABLE' literal in body must classify as View, got {kind:?}",
8461        );
8462        assert!(
8463            matches!(obj, CatalogObject::View(_)),
8464            "expected CatalogObject::View, got {obj:?}",
8465        );
8466    }
8467
8468    /// A TRIGGER body that touches a TABLE must classify as Trigger.
8469    #[test]
8470    fn classify_trigger_with_table_in_body_is_trigger() {
8471        let ddl = "CREATE OR REPLACE TRIGGER hr.t_audit \
8472                   AFTER INSERT ON hr.employees \
8473                   BEGIN INSERT INTO hr.audit_table VALUES (:NEW.id); END;";
8474        let (kind, _obj) = classify_single(ddl);
8475        assert_eq!(kind, ObjectType::Trigger);
8476    }
8477
8478    /// A PROCEDURE body that touches a TABLE must classify as Procedure.
8479    #[test]
8480    fn classify_procedure_with_table_in_body_is_procedure() {
8481        let ddl = "CREATE OR REPLACE PROCEDURE hr.p_load \
8482                   AS BEGIN INSERT INTO hr.staging_table SELECT * FROM hr.src; END;";
8483        let (kind, _obj) = classify_single(ddl);
8484        assert_eq!(kind, ObjectType::Procedure);
8485    }
8486
8487    /// A FUNCTION body that touches a TABLE must classify as Function.
8488    #[test]
8489    fn classify_function_with_table_in_body_is_function() {
8490        let ddl = "CREATE OR REPLACE FUNCTION hr.f_count RETURN NUMBER \
8491                   AS n NUMBER; BEGIN SELECT COUNT(*) INTO n FROM hr.big_table; RETURN n; END;";
8492        let (kind, _obj) = classify_single(ddl);
8493        assert_eq!(kind, ObjectType::Function);
8494    }
8495
8496    /// `PACKAGE BODY` is a body, not a spec — the classifier returns the
8497    /// spec only, so a body file must produce zero classified objects
8498    /// (not a Package, never a Table just because the body mentions one).
8499    #[test]
8500    fn classify_package_body_with_table_is_not_package_or_table() {
8501        let ddl = "CREATE OR REPLACE PACKAGE BODY hr.billing_api AS \
8502                   PROCEDURE charge IS BEGIN INSERT INTO hr.charges_table VALUES (1); END; END;";
8503        let dir = tempdir().unwrap();
8504        std::fs::write(dir.path().join("body.sql"), ddl).unwrap();
8505        let snapshot = load_from_dbms_metadata_dir(dir.path()).unwrap();
8506        let total: usize = snapshot.schemas.values().map(|s| s.objects.len()).sum();
8507        assert_eq!(
8508            total, 0,
8509            "PACKAGE BODY must not produce a classified object (got {total})",
8510        );
8511    }
8512
8513    /// `MATERIALIZED VIEW` must classify as `ObjectType::MaterializedView`,
8514    /// never as a plain View (substring match on `VIEW` would do the
8515    /// wrong thing).
8516    #[test]
8517    fn classify_materialized_view_is_materialized_view_not_view() {
8518        let ddl = "CREATE MATERIALIZED VIEW hr.mv_emp_summary AS SELECT dept, COUNT(*) FROM hr.emp GROUP BY dept;";
8519        let (kind, obj) = classify_single(ddl);
8520        assert_eq!(
8521            kind,
8522            ObjectType::MaterializedView,
8523            "MATERIALIZED VIEW must classify as MaterializedView, got {kind:?}",
8524        );
8525        assert!(
8526            matches!(obj, CatalogObject::MaterializedView(_)),
8527            "expected CatalogObject::MaterializedView, got {obj:?}",
8528        );
8529    }
8530
8531    /// Leading block comment that contains `CREATE TABLE …` must NOT
8532    /// fool the classifier — the real CREATE is for a VIEW.
8533    #[test]
8534    fn classify_view_with_leading_block_comment_mentioning_create_table_is_view() {
8535        let ddl = "/* comment with CREATE TABLE x; */\n\
8536                   CREATE OR REPLACE VIEW hr.v_dept AS SELECT * FROM hr.dept;";
8537        let (kind, _obj) = classify_single(ddl);
8538        assert_eq!(kind, ObjectType::View);
8539    }
8540
8541    /// Leading line comments that mention `CREATE TABLE` must NOT fool
8542    /// the classifier.
8543    #[test]
8544    fn classify_procedure_with_leading_line_comments_is_procedure() {
8545        let ddl = "-- CREATE TABLE oops (x NUMBER);\n\
8546                   -- another line mentioning VIEW and TABLE\n\
8547                   CREATE OR REPLACE PROCEDURE hr.p_noop AS BEGIN NULL; END;";
8548        let (kind, _obj) = classify_single(ddl);
8549        assert_eq!(kind, ObjectType::Procedure);
8550    }
8551
8552    /// `EDITIONABLE` / `NONEDITIONABLE` / `FORCE` modifiers between
8553    /// `CREATE [OR REPLACE]` and the KIND must be skipped — they are
8554    /// not the object kind.
8555    #[test]
8556    fn classify_editionable_view_is_view() {
8557        let ddl = "CREATE OR REPLACE EDITIONABLE VIEW hr.v_emp AS SELECT * FROM hr.emp;";
8558        let (kind, _obj) = classify_single(ddl);
8559        assert_eq!(kind, ObjectType::View);
8560    }
8561
8562    /// Quoted identifiers for owner/name must not break the
8563    /// header-tokenizer-based classifier.
8564    #[test]
8565    fn classify_quoted_table_owner_name_is_table() {
8566        let ddl = "CREATE TABLE \"HR\".\"EMP\" (id NUMBER);";
8567        let (kind, _obj) = classify_single(ddl);
8568        assert_eq!(kind, ObjectType::Table);
8569    }
8570
8571    /// A double-quoted Oracle identifier containing whitespace must be
8572    /// kept whole — never truncated at the first interior space. The
8573    /// prior `split_whitespace().next()` tokenizer cut `"MY TABLE"` down
8574    /// to `MY`, corrupting the snapshot key. `extract_owner_and_name`
8575    /// works on the already-upper-cased post-header remainder, so the
8576    /// inputs here are upper-cased the way `upper_remainder()` produces.
8577    #[test]
8578    fn extract_owner_and_name_keeps_whitespace_in_quoted_identifiers() {
8579        // OWNER.NAME, both quoted, name has a space.
8580        assert_eq!(
8581            crate::extract_owner_and_name("\"HR\".\"MY TABLE\" (ID NUMBER);"),
8582            Some((Some("HR".to_string()), "MY TABLE".to_string())),
8583        );
8584        // Quoted OWNER with a space must not be dropped (no PUBLIC misroute).
8585        assert_eq!(
8586            crate::extract_owner_and_name("\"MY OWNER\".\"EMP\" (ID NUMBER);"),
8587            Some((Some("MY OWNER".to_string()), "EMP".to_string())),
8588        );
8589        // Fully-quoted, unqualified, whitespace-bearing name.
8590        assert_eq!(
8591            crate::extract_owner_and_name("\"MY TABLE\" (ID NUMBER);"),
8592            Some((None, "MY TABLE".to_string())),
8593        );
8594        // Spaceless quoted and unquoted inputs still resolve correctly.
8595        assert_eq!(
8596            crate::extract_owner_and_name("\"HR\".\"EMP\" (ID NUMBER);"),
8597            Some((Some("HR".to_string()), "EMP".to_string())),
8598        );
8599        assert_eq!(
8600            crate::extract_owner_and_name("HR.ORDERS (ID NUMBER);"),
8601            Some((Some("HR".to_string()), "ORDERS".to_string())),
8602        );
8603    }
8604
8605    /// Two distinct whitespace-bearing quoted tables in one schema must
8606    /// intern under distinct keys — the truncating tokenizer collapsed
8607    /// both `"MY TABLE"` and `"MY OTHER"` to `MY` (last-write-wins).
8608    #[test]
8609    fn quoted_whitespace_names_intern_as_distinct_keys() {
8610        let dir = tempdir().unwrap();
8611        std::fs::write(
8612            dir.path().join("a.sql"),
8613            "CREATE TABLE \"HR\".\"MY TABLE\" (id NUMBER);",
8614        )
8615        .unwrap();
8616        std::fs::write(
8617            dir.path().join("b.sql"),
8618            "CREATE TABLE \"HR\".\"MY OTHER\" (id NUMBER);",
8619        )
8620        .unwrap();
8621        let snapshot = load_from_dbms_metadata_dir(dir.path()).unwrap();
8622
8623        // Both objects land in the HR schema (no PUBLIC misroute).
8624        let hr = snapshot
8625            .schemas
8626            .iter()
8627            .find(|(s, _)| {
8628                snapshot
8629                    .interner
8630                    .resolve(s.symbol())
8631                    .is_some_and(|label| label.eq("HR"))
8632            })
8633            .map(|(_, c)| c)
8634            .expect("HR schema present");
8635        assert_eq!(hr.objects.len(), 2, "two distinct objects, no collision");
8636
8637        let mut names: Vec<&str> = hr
8638            .objects
8639            .values()
8640            .filter_map(|o| {
8641                let CatalogObject::Table(m) = o else {
8642                    return None;
8643                };
8644                snapshot.interner.resolve(m.common.name.symbol())
8645            })
8646            .collect();
8647        assert_eq!(names.len(), 2, "all HR objects must be tables");
8648        names.sort_unstable();
8649        assert_eq!(names, vec!["MY OTHER", "MY TABLE"]);
8650    }
8651
8652    /// Plain CREATE TABLE still works (negative-of-negative regression).
8653    #[test]
8654    fn classify_plain_table_is_table() {
8655        let ddl = "CREATE TABLE hr.orders (id NUMBER PRIMARY KEY, total NUMBER(12,2));";
8656        let (kind, obj) = classify_single(ddl);
8657        assert_eq!(kind, ObjectType::Table);
8658        assert!(matches!(obj, CatalogObject::Table(_)));
8659    }
8660
8661    /// A PACKAGE spec that mentions VIEW / TABLE in a comment or
8662    /// procedure name must classify as Package.
8663    #[test]
8664    fn classify_package_spec_mentioning_view_and_table_is_package() {
8665        let ddl = "CREATE OR REPLACE PACKAGE hr.report_api AS \
8666                   -- builds a VIEW over a TABLE \n\
8667                   PROCEDURE rebuild_view_from_table; END;";
8668        let (kind, _obj) = classify_single(ddl);
8669        assert_eq!(kind, ObjectType::Package);
8670    }
8671}