[go: up one dir, main page]

Skip to main content

sql_docs/
docs.rs

1//! Convert parsed SQL + extracted comments into structured documentation types.
2
3use core::fmt;
4use std::path::{Path, PathBuf};
5
6use sqlparser::ast::{Ident, ObjectName, ObjectNamePart, Spanned, Statement};
7
8use crate::{
9    ast::ParsedSqlFile,
10    comments::{Comments, LeadingCommentCapture, MultiFlatten},
11    error::DocError,
12};
13
14/// Structure for containing the `name` of the `Column` and an [`Option`] for
15/// the comment as a [`String`]
16#[derive(Clone, Debug, Eq, PartialEq)]
17pub struct ColumnDoc {
18    name: String,
19    doc: Option<String>,
20}
21impl ColumnDoc {
22    /// Creates a new [`ColumnDoc`]
23    ///
24    /// # Parameters
25    /// - name: `String` - the name of the column
26    /// - doc: `Option<String>` the comment for the column
27    #[must_use]
28    pub const fn new(name: String, doc: Option<String>) -> Self {
29        Self { name, doc }
30    }
31
32    /// Getter for the `name` field
33    #[must_use]
34    pub fn name(&self) -> &str {
35        &self.name
36    }
37
38    /// Getter for the field `doc`
39    #[must_use]
40    pub fn doc(&self) -> Option<&str> {
41        self.doc.as_deref()
42    }
43
44    /// Setter to update the table doc
45    pub fn set_doc(&mut self, doc: impl Into<String>) {
46        self.doc = Some(doc.into());
47    }
48}
49
50impl fmt::Display for ColumnDoc {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        writeln!(f, "Column Name: {}", self.name())?;
53        if let Some(c) = self.doc() {
54            writeln!(f, "Column Doc: {c}")?;
55        } else {
56            writeln!(f, "No Column Doc Found")?;
57        }
58        Ok(())
59    }
60}
61
62/// Structure for containing the `name` of the `Table`, an [`Option`] for if the
63/// table has a schema,  an [`Option`] for the comment as a [`String`], and a
64/// `Vec` of [`ColumnDoc`] contained in the table
65#[derive(Clone, Debug, Eq, PartialEq)]
66pub struct TableDoc {
67    schema: Option<String>,
68    name: String,
69    doc: Option<String>,
70    columns: Vec<ColumnDoc>,
71    path: Option<PathBuf>,
72}
73
74impl TableDoc {
75    /// Creates a new [`TableDoc`] after sorting [`ColumnDoc`] by `name`
76    ///
77    /// # Parameters
78    /// - name: `String` - the name of the table
79    /// - doc: `Option<String>` of the comment for table
80    /// - columns: the `Vec<ColumnDoc>` of all [`ColumnDoc`] for this table
81    #[must_use]
82    #[allow(clippy::missing_const_for_fn)]
83    pub fn new(
84        schema: Option<String>,
85        name: String,
86        doc: Option<String>,
87        mut columns: Vec<ColumnDoc>,
88        path: Option<PathBuf>,
89    ) -> Self {
90        columns.sort_by(|a, b| a.name().cmp(b.name()));
91        Self { schema, name, doc, columns, path }
92    }
93
94    /// Getter for the `Schema` of the table (if there is one)
95    #[must_use]
96    pub fn schema(&self) -> Option<&str> {
97        self.schema.as_deref()
98    }
99
100    /// Getter for the `name` field
101    #[must_use]
102    pub fn name(&self) -> &str {
103        &self.name
104    }
105
106    /// Getter for the `doc` field
107    #[must_use]
108    pub fn doc(&self) -> Option<&str> {
109        self.doc.as_deref()
110    }
111
112    /// Setter to update the table doc
113    pub fn set_doc(&mut self, doc: impl Into<String>) {
114        self.doc = Some(doc.into());
115    }
116
117    /// Setter for updating the table [`PathBuf`] source
118    pub fn set_path(&mut self, path: Option<impl Into<PathBuf>>) {
119        self.path = path.map(Into::into);
120    }
121
122    /// Getter for the `columns` field
123    #[must_use]
124    pub fn columns(&self) -> &[ColumnDoc] {
125        &self.columns
126    }
127
128    /// Getter that returns a mutable reference to the [`ColumnDoc`] vec
129    pub fn columns_mut(&mut self) -> &mut [ColumnDoc] {
130        &mut self.columns
131    }
132
133    /// Method for finding a specific [`ColumnDoc`] from `schema` and table `name`
134    ///
135    /// # Parameters
136    /// - the column's `name` as a [`str`]
137    ///
138    /// # Errors
139    /// - Will return [`DocError::ColumnNotFound`] if the expected table is not found
140    /// - Will return [`DocError::DuplicateColumnsFound`] if more than one column matches
141    pub fn column(&self, name: &str) -> Result<&ColumnDoc, DocError> {
142        let columns = self.columns();
143        let start = columns.partition_point(|n| n.name() < name);
144        if start == columns.len() || columns[start].name() != name {
145            return Err(DocError::ColumnNotFound { name: name.to_owned() });
146        }
147        let end = columns.partition_point(|n| n.name() <= name);
148        match &columns[start..end] {
149            [single] => Ok(single),
150            multiple => Err(DocError::DuplicateColumnsFound { columns: multiple.to_vec() }),
151        }
152    }
153
154    /// Getter method for retrieving the table's [`Path`]
155    #[must_use]
156    pub fn path(&self) -> Option<&Path> {
157        self.path.as_deref()
158    }
159
160    /// Returns the number of [`ColumnDoc`]
161    #[must_use]
162    pub fn number_of_columns(&self) -> usize {
163        self.columns().len()
164    }
165}
166
167impl fmt::Display for TableDoc {
168    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169        if let Some(s) = self.schema() {
170            writeln!(f, "Table Schema: {s}")?;
171        } else {
172            writeln!(f, "No Table Schema")?;
173        }
174
175        writeln!(f, "Table Name: {}", self.name())?;
176
177        if let Some(d) = self.doc() {
178            writeln!(f, "Table Doc: {d}")?;
179        } else {
180            writeln!(f, "No Table Doc")?;
181        }
182
183        writeln!(f, "Table Column Docs: ")?;
184        for col in self.columns() {
185            write!(f, " {col}")?;
186        }
187        Ok(())
188    }
189}
190
191/// Structure for containing the docs for every `Table` in an `.sql` file as a
192/// `Vec` of [`TableDoc`]
193#[derive(Clone, Debug, Eq, PartialEq)]
194pub struct SqlFileDoc {
195    tables: Vec<TableDoc>,
196}
197
198impl SqlFileDoc {
199    /// Create a new instance of [`SqlFileDoc`]
200    ///
201    /// # Parameters
202    /// - `tables` the `Vec` of [`TableDoc`] for the struct
203    #[must_use]
204    pub const fn new(tables: Vec<TableDoc>) -> Self {
205        Self { tables }
206    }
207
208    /// Final structured documentation extracted from one SQL file.
209    ///
210    /// This merges:
211    /// - Parsed SQL AST (`CREATE TABLE` statements for example)
212    /// - Comment spans into a format suitable for documentation generation.
213    ///
214    /// # Parameters
215    /// - `file`: the [`ParsedSqlFile`]
216    /// - `comments`: the parsed [`Comments`]
217    ///
218    /// # Errors
219    /// - Returns [`DocError::InvalidObjectName`] if the table name has no identifier components.
220    /// - May also propagate other [`DocError`] variants from lower layers in the future.
221    pub fn from_parsed_file(
222        file: &ParsedSqlFile,
223        comments: &Comments,
224        capture: LeadingCommentCapture,
225        flatten: MultiFlatten,
226    ) -> Result<Self, DocError> {
227        let mut tables = Vec::new();
228        for statement in file.statements() {
229            #[allow(clippy::single_match)]
230            match statement {
231                Statement::CreateTable(table) => {
232                    let table_start = table.span().start.line;
233                    let mut column_docs = Vec::new();
234                    for column in &table.columns {
235                        let column_start = column.span().start.line;
236                        let column_leading = comments
237                            .leading_comments(column_start, capture)
238                            .collapse_comments(flatten);
239                        let column_name = column.name.value.clone();
240                        let column_doc = match column_leading {
241                            Some(col_comment) => {
242                                ColumnDoc::new(column_name, Some(col_comment.text().to_owned()))
243                            }
244                            None => ColumnDoc::new(column_name, None),
245                        };
246                        column_docs.push(column_doc);
247                    }
248                    let table_leading =
249                        comments.leading_comments(table_start, capture).collapse_comments(flatten);
250                    let (schema, name) = schema_and_table(&table.name)?;
251                    let table_doc = TableDoc::new(
252                        schema,
253                        name,
254                        table_leading.as_ref().map(|c| c.text().to_owned()),
255                        column_docs,
256                        file.path_into_path_buf(),
257                    );
258                    tables.push(table_doc);
259                }
260                // can add support for other types of statements below
261                _ => {}
262            }
263        }
264
265        Ok(Self { tables })
266    }
267
268    /// Getter function to get a slice of [`TableDoc`]
269    #[must_use]
270    pub fn tables(&self) -> &[TableDoc] {
271        &self.tables
272    }
273
274    /// Getter that returns a mutable reference to the [`TableDoc`] vec
275    pub fn tables_mut(&mut self) -> &mut [TableDoc] {
276        &mut self.tables
277    }
278
279    /// Returns the number fo tables in the `SqlFileDoc`
280    #[must_use]
281    pub fn number_of_tables(&self) -> usize {
282        self.tables().len()
283    }
284}
285
286/// Converts a file doc into its table docs (consumes the [`SqlFileDoc`]).
287impl From<SqlFileDoc> for Vec<TableDoc> {
288    fn from(value: SqlFileDoc) -> Self {
289        value.tables
290    }
291}
292
293impl IntoIterator for SqlFileDoc {
294    type Item = TableDoc;
295    type IntoIter = <Vec<TableDoc> as IntoIterator>::IntoIter;
296    fn into_iter(self) -> Self::IntoIter {
297        self.tables.into_iter()
298    }
299}
300
301/// Helper function that will parse the table's schema and table name.
302/// Easily extensible for catalog if neeeded as well.
303///
304/// # Parameters
305/// - `name` the [`ObjectName`] structure for the statement
306///
307/// # Errors
308/// - [`DocError`] will return the location of the statement if there is a statement without a schema and table name.
309fn schema_and_table(name: &ObjectName) -> Result<(Option<String>, String), DocError> {
310    let idents: Vec<&Ident> = name
311        .0
312        .iter()
313        .filter_map(|part| match part {
314            ObjectNamePart::Identifier(ident) => Some(ident),
315            ObjectNamePart::Function(_func) => None,
316        })
317        .collect();
318
319    match idents.as_slice() {
320        [] => {
321            let span = name.span();
322            Err(DocError::InvalidObjectName {
323                message: "ObjectName had no identifier parts".to_owned(),
324                line: span.start.line,
325                column: span.start.column,
326            })
327        }
328        [only] => Ok((None, only.value.clone())),
329        [.., schema, table] => Ok((Some(schema.value.clone()), table.value.clone())),
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use core::fmt;
336    use std::{env, fs, path::PathBuf};
337
338    use sqlparser::{
339        ast::{Ident, ObjectName, ObjectNamePart, ObjectNamePartFunction},
340        tokenizer::Span,
341    };
342
343    use crate::{
344        docs::{ColumnDoc, SqlFileDoc, TableDoc, schema_and_table},
345        error::DocError,
346    };
347
348    #[test]
349    fn test_sql_docs_struct() {
350        let column_doc = ColumnDoc::new("id".to_owned(), Some("The ID for the table".to_owned()));
351        let columns = vec![column_doc];
352        let table_doc = TableDoc::new(
353            None,
354            "user".to_owned(),
355            Some("The table for users".to_owned()),
356            columns,
357            None,
358        );
359        let tables = vec![table_doc];
360        let sql_doc = SqlFileDoc::new(tables);
361        let sql_doc_val =
362            sql_doc.tables().first().unwrap_or_else(|| panic!("unable to find table"));
363        assert_eq!(sql_doc_val.name(), "user");
364        let sql_doc_val_column =
365            sql_doc_val.columns().first().unwrap_or_else(|| panic!("unable to find columns"));
366        assert_eq!(sql_doc_val_column.name(), "id");
367    }
368
369    fn single_line_comments_sql() -> &'static str {
370        "-- Users table stores user account information
371CREATE TABLE users (
372    -- Primary key
373    id INTEGER PRIMARY KEY,
374    -- Username for login
375    username VARCHAR(255) NOT NULL,
376    -- Email address
377    email VARCHAR(255) UNIQUE NOT NULL,
378    -- When the user registered
379    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
380);
381
382-- Posts table stores blog posts
383CREATE TABLE posts (
384    -- Primary key
385    id INTEGER PRIMARY KEY,
386    -- Post title
387    title VARCHAR(255) NOT NULL,
388    -- Foreign key linking to users
389    user_id INTEGER NOT NULL,
390    -- Main body text
391    body TEXT NOT NULL,
392    -- When the post was created
393    published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
394);"
395    }
396
397    fn multiline_comments_sql() -> &'static str {
398        r"/* Users table stores user account information 
399multiline */
400CREATE TABLE users (
401    /* Primary key 
402    multiline */
403    id INTEGER PRIMARY KEY,
404    /* Username for login 
405    multiline */
406    username VARCHAR(255) NOT NULL,
407    /* Email address 
408    multiline */
409    email VARCHAR(255) UNIQUE NOT NULL,
410    /* When the user registered 
411    multiline */
412    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
413);
414
415/* Posts table stores blog posts 
416multiline */
417CREATE TABLE posts (
418    /* Primary key 
419    multiline */
420    id INTEGER PRIMARY KEY,
421    /* Post title 
422    multiline */
423    title VARCHAR(255) NOT NULL,
424    /* Foreign key linking to users 
425    multiline */
426    user_id INTEGER NOT NULL,
427    /* Main body text 
428    multiline */
429    body TEXT NOT NULL,
430    /* When the post was created 
431    multiline */
432    published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
433);"
434    }
435
436    fn no_comments_sql() -> &'static str {
437        "CREATE TABLE users (
438    id INTEGER PRIMARY KEY,
439    username VARCHAR(255) NOT NULL,
440    email VARCHAR(255) UNIQUE NOT NULL,
441    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
442);
443
444CREATE TABLE posts (
445    id INTEGER PRIMARY KEY,
446    title VARCHAR(255) NOT NULL,
447    user_id INTEGER NOT NULL,
448    body TEXT NOT NULL,
449    published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
450);"
451    }
452
453    fn mixed_comments_sql() -> &'static str {
454        "-- interstitial Comment above statements (should be ignored)
455
456/* Users table stores user account information */
457CREATE TABLE users ( /* users interstitial comment 
458(should be ignored) */
459    -- Primary key
460    id INTEGER PRIMARY KEY, -- Id comment that is interstitial (should be ignored)
461    /* Username for login */
462    username VARCHAR(255) NOT NULL,
463    -- Email address
464    email VARCHAR(255) UNIQUE NOT NULL,
465    /* When the user registered */
466    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
467);
468
469/* Posts table stores blog posts */
470CREATE TABLE posts (
471    -- Primary key
472    id INTEGER PRIMARY KEY,
473    /* Post title */
474    title VARCHAR(255) NOT NULL,
475    -- Foreign key linking to users
476    user_id INTEGER NOT NULL,
477    /* Main body text */
478    body TEXT NOT NULL,
479    -- When the post was created
480    published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
481);
482"
483    }
484
485    #[test]
486    fn generate_docs_files() -> Result<(), Box<dyn std::error::Error>> {
487        use crate::{ast::ParsedSqlFileSet, comments::Comments, source::SqlSource};
488        let base = env::temp_dir().join("all_sql_files2");
489        let _ = fs::remove_dir_all(&base);
490        fs::create_dir_all(&base)?;
491        let file1 = base.join("with_single_line_comments.sql");
492        fs::File::create(&file1)?;
493        fs::write(&file1, single_line_comments_sql())?;
494        let file2 = base.join("with_multiline_comments.sql");
495        fs::File::create(&file2)?;
496        fs::write(&file2, multiline_comments_sql())?;
497        let file3 = base.join("with_mixed_comments.sql");
498        fs::File::create(&file3)?;
499        fs::write(&file3, mixed_comments_sql())?;
500        let file4 = base.join("without_comments.sql");
501        fs::File::create(&file4)?;
502        fs::write(&file4, no_comments_sql())?;
503        let set = SqlSource::sql_sources(&base, &[])?;
504        let parsed_set = ParsedSqlFileSet::parse_all(set)?;
505        let expected_values = expect_values();
506        let capture = crate::comments::LeadingCommentCapture::default();
507
508        for file in parsed_set.files() {
509            let comments = Comments::parse_all_comments_from_file(file)?;
510            let docs = SqlFileDoc::from_parsed_file(
511                file,
512                &comments,
513                capture,
514                crate::comments::MultiFlatten::NoFlat,
515            );
516            let filename = file
517                .file()
518                .path()
519                .and_then(|p| p.file_name())
520                .and_then(|s| s.to_str())
521                .ok_or("unable to parse file")?;
522
523            let got = docs?;
524            let file_path = file.file().path().ok_or("missing path")?;
525
526            match filename {
527                "with_single_line_comments.sql" | "with_mixed_comments.sql" => {
528                    let expected = with_path(expected_values[0].clone(), file_path);
529                    assert_eq!(&got, &expected);
530                }
531                "with_multiline_comments.sql" => {
532                    let expected = with_path(expected_values[1].clone(), file_path);
533                    assert_eq!(&got, &expected);
534                }
535                "without_comments.sql" => {
536                    let expected = with_path(expected_without_comments_docs(), file_path);
537                    assert_eq!(&got, &expected);
538                }
539                _ => unreachable!(),
540            }
541        }
542        let _ = fs::remove_dir_all(&base);
543        Ok(())
544    }
545
546    fn with_path(mut doc: SqlFileDoc, path: &std::path::Path) -> SqlFileDoc {
547        let pb = path.to_path_buf();
548
549        for table in doc.tables_mut() {
550            table.set_path(Some(pb.clone()));
551        }
552
553        doc
554    }
555
556    fn expected_without_comments_docs() -> SqlFileDoc {
557        SqlFileDoc::new(vec![
558            TableDoc::new(
559                None,
560                "users".to_owned(),
561                None,
562                vec![
563                    ColumnDoc::new("id".to_owned(), None),
564                    ColumnDoc::new("username".to_owned(), None),
565                    ColumnDoc::new("email".to_owned(), None),
566                    ColumnDoc::new("created_at".to_owned(), None),
567                ],
568                None,
569            ),
570            TableDoc::new(
571                None,
572                "posts".to_owned(),
573                None,
574                vec![
575                    ColumnDoc::new("id".to_owned(), None),
576                    ColumnDoc::new("title".to_owned(), None),
577                    ColumnDoc::new("user_id".to_owned(), None),
578                    ColumnDoc::new("body".to_owned(), None),
579                    ColumnDoc::new("published_at".to_owned(), None),
580                ],
581                None,
582            ),
583        ])
584    }
585
586    fn expect_values() -> Vec<SqlFileDoc> {
587        let mut docs = Vec::new();
588
589        let first_docs = SqlFileDoc::new(vec![
590            TableDoc::new(
591                None,
592                "users".to_owned(),
593                Some("Users table stores user account information".to_owned()),
594                vec![
595                    ColumnDoc::new("id".to_owned(), Some("Primary key".to_owned())),
596                    ColumnDoc::new("username".to_owned(), Some("Username for login".to_owned())),
597                    ColumnDoc::new("email".to_owned(), Some("Email address".to_owned())),
598                    ColumnDoc::new(
599                        "created_at".to_owned(),
600                        Some("When the user registered".to_owned()),
601                    ),
602                ],
603                None,
604            ),
605            TableDoc::new(
606                None,
607                "posts".to_owned(),
608                Some("Posts table stores blog posts".to_owned()),
609                vec![
610                    ColumnDoc::new("id".to_owned(), Some("Primary key".to_owned())),
611                    ColumnDoc::new("title".to_owned(), Some("Post title".to_owned())),
612                    ColumnDoc::new(
613                        "user_id".to_owned(),
614                        Some("Foreign key linking to users".to_owned()),
615                    ),
616                    ColumnDoc::new("body".to_owned(), Some("Main body text".to_owned())),
617                    ColumnDoc::new(
618                        "published_at".to_owned(),
619                        Some("When the post was created".to_owned()),
620                    ),
621                ],
622                None,
623            ),
624        ]);
625        docs.push(first_docs);
626
627        let second_docs = SqlFileDoc::new(vec![
628            TableDoc::new(
629                None,
630                "users".to_owned(),
631                Some("Users table stores user account information\nmultiline".to_owned()),
632                vec![
633                    ColumnDoc::new("id".to_owned(), Some("Primary key\nmultiline".to_owned())),
634                    ColumnDoc::new(
635                        "username".to_owned(),
636                        Some("Username for login\nmultiline".to_owned()),
637                    ),
638                    ColumnDoc::new("email".to_owned(), Some("Email address\nmultiline".to_owned())),
639                    ColumnDoc::new(
640                        "created_at".to_owned(),
641                        Some("When the user registered\nmultiline".to_owned()),
642                    ),
643                ],
644                None,
645            ),
646            TableDoc::new(
647                None,
648                "posts".to_owned(),
649                Some("Posts table stores blog posts\nmultiline".to_owned()),
650                vec![
651                    ColumnDoc::new("id".to_owned(), Some("Primary key\nmultiline".to_owned())),
652                    ColumnDoc::new("title".to_owned(), Some("Post title\nmultiline".to_owned())),
653                    ColumnDoc::new(
654                        "user_id".to_owned(),
655                        Some("Foreign key linking to users\nmultiline".to_owned()),
656                    ),
657                    ColumnDoc::new("body".to_owned(), Some("Main body text\nmultiline".to_owned())),
658                    ColumnDoc::new(
659                        "published_at".to_owned(),
660                        Some("When the post was created\nmultiline".to_owned()),
661                    ),
662                ],
663                None,
664            ),
665        ]);
666        docs.push(second_docs);
667
668        docs
669    }
670
671    #[test]
672    fn test_doc() {
673        let col_doc = ColumnDoc::new("test".to_owned(), Some("comment".to_owned()));
674        assert_eq!(&col_doc.to_string(), &"Column Name: test\nColumn Doc: comment\n".to_owned());
675        let col_doc_no_doc = ColumnDoc::new("id".to_owned(), None);
676        assert_eq!(
677            &col_doc_no_doc.to_string(),
678            &"Column Name: id\nNo Column Doc Found\n".to_owned()
679        );
680        assert_eq!(col_doc.doc(), Some("comment"));
681        assert_eq!(col_doc.name(), "test");
682        assert_eq!(col_doc_no_doc.doc(), None);
683        assert_eq!(col_doc_no_doc.name(), "id");
684        let table_doc = TableDoc::new(
685            Some("schema".to_owned()),
686            "table".to_owned(),
687            Some("table doc".to_owned()),
688            vec![col_doc.clone(), col_doc_no_doc.clone()],
689            None,
690        );
691        assert_eq!(table_doc.number_of_columns(), 2);
692        let last_col = ColumnDoc::new("zed".to_owned(), Some("the last column".to_owned()));
693        let table_doc_no_doc = TableDoc::new(
694            None,
695            "table".to_owned(),
696            None,
697            vec![last_col, col_doc, col_doc_no_doc],
698            None,
699        );
700        assert_eq!(table_doc_no_doc.number_of_columns(), 3);
701        assert_eq!(table_doc.name(), "table");
702        assert_eq!(table_doc.schema(), Some("schema"));
703        assert_eq!(
704            table_doc.to_string(),
705            "Table Schema: schema\nTable Name: table\nTable Doc: table doc\nTable Column Docs: \n Column Name: id\nNo Column Doc Found\n Column Name: test\nColumn Doc: comment\n"
706        );
707        assert_eq!(table_doc_no_doc.schema(), None);
708        assert_eq!(table_doc_no_doc.name(), "table");
709        assert_eq!(
710            table_doc_no_doc.to_string(),
711            "No Table Schema\nTable Name: table\nNo Table Doc\nTable Column Docs: \n Column Name: id\nNo Column Doc Found\n Column Name: test\nColumn Doc: comment\n Column Name: zed\nColumn Doc: the last column\n"
712        );
713    }
714
715    fn ident(v: &str) -> Ident {
716        Ident { value: v.to_owned(), quote_style: None, span: Span::empty() }
717    }
718
719    fn func_part(name: &str) -> ObjectNamePart {
720        ObjectNamePart::Function(ObjectNamePartFunction { name: ident(name), args: vec![] })
721    }
722
723    #[test]
724    fn schema_and_table_errors_when_no_identifier_parts() {
725        let name = ObjectName(vec![func_part("now")]);
726
727        let err = match schema_and_table(&name) {
728            Ok(v) => panic!("expected Err(DocError::InvalidObjectName), got Ok({v:?})"),
729            Err(e) => e,
730        };
731
732        match err {
733            DocError::InvalidObjectName { message, .. } => {
734                assert_eq!(message, "ObjectName had no identifier parts");
735            }
736            other => panic!("unexpected error: {other:?}"),
737        }
738    }
739
740    #[test]
741    fn schema_and_table_single_identifier() {
742        let name = ObjectName(vec![ObjectNamePart::Identifier(ident("users"))]);
743
744        let (schema, table) = match schema_and_table(&name) {
745            Ok(v) => v,
746            Err(e) => panic!("unexpected error: {e:?}"),
747        };
748
749        assert_eq!(schema, None);
750        assert_eq!(table, "users");
751    }
752
753    #[test]
754    fn schema_and_table_schema_and_table_with_function_ignored()
755    -> Result<(), Box<dyn std::error::Error>> {
756        let name = ObjectName(vec![
757            ObjectNamePart::Identifier(ident("catalog")),
758            ObjectNamePart::Identifier(ident("public")),
759            func_part("some_func"),
760            ObjectNamePart::Identifier(ident("orders")),
761        ]);
762
763        let (schema, table) = schema_and_table(&name)?;
764        assert_eq!(schema, Some("public".to_owned()));
765        assert_eq!(table, "orders");
766        Ok(())
767    }
768
769    struct FailOnNthWrite {
770        fail_at: usize,
771        writes: usize,
772        sink: String,
773    }
774
775    impl FailOnNthWrite {
776        fn new(fail_at: usize) -> Self {
777            Self { fail_at, writes: 0, sink: String::new() }
778        }
779    }
780
781    impl fmt::Write for FailOnNthWrite {
782        fn write_str(&mut self, s: &str) -> fmt::Result {
783            self.writes += 1;
784            if self.writes == self.fail_at {
785                return Err(fmt::Error);
786            }
787            self.sink.push_str(s);
788            Ok(())
789        }
790    }
791
792    fn run_fail_at<T: fmt::Display>(v: &T, fail_at: usize) -> Result<(), fmt::Error> {
793        let mut w = FailOnNthWrite::new(fail_at);
794        fmt::write(&mut w, format_args!("{v}"))
795    }
796
797    fn count_writes<T: fmt::Display>(v: &T) -> usize {
798        let mut w = FailOnNthWrite { fail_at: usize::MAX, writes: 0, sink: String::new() };
799        let _ = fmt::write(&mut w, format_args!("{v}"));
800        w.writes
801    }
802
803    #[test]
804    fn test_display_propagates_every_question_mark_path_for_column_and_table() {
805        let col_with_doc = ColumnDoc::new("col_a".into(), Some("doc".into()));
806        let col_without_doc = ColumnDoc::new("col_b".into(), None);
807
808        let table = TableDoc::new(
809            Some("public".into()),
810            "users".into(),
811            Some("table doc".into()),
812            vec![col_with_doc.clone(), col_without_doc],
813            None,
814        );
815
816        let col_writes = count_writes(&col_with_doc);
817        let table_writes = count_writes(&table);
818
819        for i in 1..=col_writes {
820            assert!(
821                run_fail_at(&col_with_doc, i).is_err(),
822                "ColumnDoc should error when failing at write #{i} (total writes {col_writes})"
823            );
824        }
825
826        for i in 1..=table_writes {
827            assert!(
828                run_fail_at(&table, i).is_err(),
829                "TableDoc should error when failing at write #{i} (total writes {table_writes})"
830            );
831        }
832    }
833
834    #[test]
835    fn column_doc_set_doc_updates_doc() {
836        let mut col = ColumnDoc::new("id".to_owned(), None);
837        assert_eq!(col.name(), "id");
838        assert_eq!(col.doc(), None);
839        col.set_doc("primary key");
840        assert_eq!(col.doc(), Some("primary key"));
841        let new_doc = String::from("primary key for users table");
842        col.set_doc(new_doc);
843        assert_eq!(col.doc(), Some("primary key for users table"));
844    }
845
846    #[test]
847    fn table_doc_set_doc_updates_doc() {
848        let mut table = TableDoc::new(None, "users".to_owned(), None, Vec::new(), None);
849        assert_eq!(table.name(), "users");
850        assert_eq!(table.schema(), None);
851        assert_eq!(table.doc(), None);
852        table.set_doc("users table docs");
853        assert_eq!(table.doc(), Some("users table docs"));
854        table.set_doc(String::from("updated users table docs"));
855        assert_eq!(table.doc(), Some("updated users table docs"));
856    }
857
858    #[test]
859    fn columns_mut_allows_mutating_column_docs() {
860        let mut table = TableDoc::new(
861            None,
862            "users".to_owned(),
863            None,
864            vec![
865                ColumnDoc::new("id".to_owned(), None),
866                ColumnDoc::new("username".to_owned(), None),
867            ],
868            None,
869        );
870
871        {
872            let cols_mut = table.columns_mut();
873            assert_eq!(cols_mut.len(), 2);
874            cols_mut[0].set_doc("primary key");
875            cols_mut[1].set_doc("login name");
876        }
877
878        let cols = table.columns();
879        assert_eq!(cols[0].name(), "id");
880        assert_eq!(cols[0].doc(), Some("primary key"));
881        assert_eq!(cols[1].name(), "username");
882        assert_eq!(cols[1].doc(), Some("login name"));
883    }
884    #[test]
885    fn test_from_sql_file_doc_into_vec_table_doc_preserves_contents_and_order() {
886        let t1 = TableDoc::new(
887            None,
888            "users".to_owned(),
889            Some("users doc".to_owned()),
890            vec![ColumnDoc::new("id".to_owned(), Some("pk".to_owned()))],
891            None,
892        );
893        let t2 = TableDoc::new(
894            Some("analytics".to_owned()),
895            "events".to_owned(),
896            None,
897            vec![ColumnDoc::new("payload".to_owned(), None)],
898            None,
899        );
900
901        let sql_file_doc = SqlFileDoc::new(vec![t1.clone(), t2.clone()]);
902        let got: Vec<TableDoc> = Vec::from(sql_file_doc);
903
904        let expected = vec![t1, t2];
905        assert_eq!(got, expected);
906    }
907    #[test]
908    fn test_table_doc_path_getter_returns_expected_value() {
909        let mut table = TableDoc::new(None, "users".to_owned(), None, Vec::new(), None);
910        assert_eq!(table.path(), None);
911        let pb = PathBuf::from("some/dir/file.sql");
912        table.set_path(Some(pb.clone()));
913        assert_eq!(table.path(), Some(pb.as_path()));
914        let no_path: Option<PathBuf> = None;
915        table.set_path(no_path);
916        assert_eq!(table.path(), None);
917    }
918
919    #[test]
920    fn test_table_doc_column() {
921        let table = TableDoc::new(
922            None,
923            "users".to_owned(),
924            None,
925            vec![
926                ColumnDoc::new("id".to_owned(), None),
927                ColumnDoc::new("username".to_owned(), None),
928            ],
929            None,
930        );
931
932        let col = table.column("id").unwrap_or_else(|_| panic!("Column 'id' should exist"));
933        assert_eq!(col.name(), "id");
934
935        let missing = table.column("nope");
936        assert!(missing.is_err());
937        assert!(matches!(missing, Err(DocError::ColumnNotFound { name }) if name == "nope"));
938    }
939}