[go: up one dir, main page]

Skip to main content

sql_docs/
sql_doc.rs

1//! Public entry point for building [`SqlDoc`] from a directory, file, or string.
2
3use std::{
4    path::{Path, PathBuf},
5    str::FromStr,
6    vec,
7};
8
9use crate::{
10    ast::ParsedSqlFile,
11    comments::{Comments, LeadingCommentCapture, MultiFlatten},
12    docs::{SqlFileDoc, TableDoc},
13    error::DocError,
14    files::SqlFiles,
15    source::SqlSource,
16};
17
18/// Top-level documentation object containing all discovered [`TableDoc`] entries.
19#[derive(Clone, Debug, Eq, PartialEq)]
20pub struct SqlDoc {
21    /// Holds the [`Vec`] of all tables found in all specified files.
22    tables: Vec<TableDoc>,
23}
24
25impl SqlDoc {
26    /// Method for creating a new [`SqlDoc`]
27    #[must_use]
28    pub fn new(mut tables: Vec<TableDoc>) -> Self {
29        tables.sort_by(|a, b| a.name().cmp(b.name()));
30        Self { tables }
31    }
32
33    /// Creates an [`SqlDocBuilder`] that will scan a directory for SQL files and build an [`SqlDoc`].
34    ///
35    /// This is the most convenient entry point when you have a folder of `.sql` files.
36    /// The returned builder can be further configured with builder methods before calling [`SqlDocBuilder::build`].
37    ///
38    /// # Parameters
39    /// - `root`: Path to the directory containing SQL files.
40    ///
41    /// # Examples
42    /// ```no_run
43    /// use sql_docs::sql_doc::SqlDoc;
44    ///
45    /// let doc = SqlDoc::from_dir("migrations")
46    ///     .deny("migrations/old/ignore.sql")
47    ///     .build()
48    ///     .unwrap();
49    ///
50    /// // Work with table docs
51    /// let users = doc.table("users", None).unwrap();
52    /// assert_eq!(users.name(), "users");
53    /// ```
54    pub fn from_dir<P: AsRef<Path> + ?Sized>(root: &P) -> SqlDocBuilder<'_> {
55        SqlDocBuilder {
56            source: SqlFileDocSource::Dir(root.as_ref().to_path_buf()),
57            deny: Vec::new(),
58            multiline_flat: MultiFlatten::default(),
59            leading_type: LeadingCommentCapture::default(),
60        }
61    }
62
63    /// Creates an [`SqlDocBuilder`] from a single SQL file on disk.
64    ///
65    /// Use this when you want documentation for one specific file. The resulting tables will have their
66    /// `path` stamped from the provided file path (see tests such as `build_sql_doc_from_file`).
67    ///
68    /// # Parameters
69    /// - `path`: Path to a single SQL file.
70    ///
71    /// # Examples
72    /// ```no_run
73    /// use sql_docs::sql_doc::SqlDoc;
74    ///
75    /// let doc = SqlDoc::from_path("schema.sql")
76    ///     .build()
77    ///     .unwrap();
78    ///
79    /// let t = doc.table("users", None).unwrap();
80    /// assert_eq!(t.name(), "users");
81    /// ```
82    pub fn from_path<P: AsRef<Path> + ?Sized>(path: &P) -> SqlDocBuilder<'_> {
83        SqlDocBuilder {
84            source: SqlFileDocSource::File(path.as_ref().to_path_buf()),
85            deny: Vec::new(),
86            multiline_flat: MultiFlatten::default(),
87            leading_type: LeadingCommentCapture::default(),
88        }
89    }
90
91    /// Creates an [`SqlDocBuilder`] from an explicit list of SQL file paths.
92    ///
93    /// This is useful when the files you want are scattered across directories, or when you already
94    /// have an exact list (e.g. selected by another tool). Each parsed table will have its `path`
95    /// stamped based on the file it came from (see `test_build_sql_doc_from_paths`).
96    ///
97    /// # Parameters
98    /// - `paths`: Slice of paths to SQL files.
99    ///
100    /// # Examples
101    /// ```no_run
102    /// use sql_docs::sql_doc::SqlDoc;
103    ///
104    /// let paths = vec!["one.sql", "two.sql"];
105    /// let doc = SqlDoc::from_paths(&paths)
106    ///     .build()
107    ///     .unwrap();
108    ///
109    /// assert!(doc.table("users", None).is_ok());
110    /// assert!(doc.table("posts", None).is_ok());
111    /// ```
112    pub fn from_paths<P: AsRef<Path>>(paths: &[P]) -> SqlDocBuilder<'_> {
113        SqlDocBuilder {
114            source: SqlFileDocSource::Files(
115                paths.iter().map(|p| p.as_ref().to_path_buf()).collect(),
116            ),
117            deny: Vec::new(),
118            multiline_flat: MultiFlatten::default(),
119            leading_type: LeadingCommentCapture::default(),
120        }
121    }
122
123    /// Creates an [`SqlDocBuilder`] from raw SQL text.
124    ///
125    /// This does **not** associate any filesystem path with the input, so discovered tables will have
126    /// `path == None` (see `test_builder_from_str_no_path_has_none_path`).
127    ///
128    /// This is handy for:
129    /// - tests
130    /// - parsing SQL from a network source
131    /// - parsing SQL assembled in-memory
132    ///
133    /// # Parameters
134    /// - `content`: SQL text to parse.
135    ///
136    /// # Examples
137    /// ```
138    /// use sql_docs::sql_doc::SqlDoc;
139    ///
140    /// let sql = r#"
141    ///     -- Users table
142    ///     CREATE TABLE users (id INTEGER PRIMARY KEY);
143    /// "#;
144    ///
145    /// let doc = SqlDoc::builder_from_str(sql).build().unwrap();
146    /// let users = doc.table("users", None).unwrap();
147    ///
148    /// // No backing file path when built from a string:
149    /// assert_eq!(users.path(), None);
150    /// ```
151    #[must_use]
152    pub fn builder_from_str(content: &str) -> SqlDocBuilder<'_> {
153        SqlDocBuilder {
154            source: SqlFileDocSource::FromString(content),
155            deny: Vec::new(),
156            multiline_flat: MultiFlatten::default(),
157            leading_type: LeadingCommentCapture::default(),
158        }
159    }
160
161    /// Creates an [`SqlDocBuilder`] from from raw SQL text while preserving an associated path.
162    ///
163    /// Each tuple is interpreted as:
164    /// - `String`: the SQL text to parse
165    /// - `PathBuf`: the path to associate with that SQL text
166    ///
167    ///
168    /// # Parameters
169    /// - `string_with_path`: Slice of `(sql, path)` pairs, where `sql` is the SQL text and `path` is
170    ///   the path that should be attached to any discovered tables.
171    ///
172    /// # Examples
173    /// ```
174    /// use std::path::PathBuf;
175    /// use sql_docs::sql_doc::SqlDoc;
176    ///
177    /// let sql_users = "CREATE TABLE users (id INTEGER PRIMARY KEY);".to_owned();
178    /// let sql_posts = "CREATE TABLE posts (id INTEGER PRIMARY KEY);".to_owned();
179    ///
180    /// let p1 = PathBuf::from("a/users.sql");
181    /// let p2 = PathBuf::from("b/posts.sql");
182    ///
183    /// let inputs = vec![(sql_users, p1.clone()), (sql_posts, p2.clone())];
184    ///
185    /// let doc = SqlDoc::builder_from_strs_with_paths(&inputs).build().unwrap();
186    ///
187    /// let users = doc.table("users", None).unwrap();
188    /// let posts = doc.table("posts", None).unwrap();
189    ///
190    /// assert_eq!(users.path(), Some(p1.as_path()));
191    /// assert_eq!(posts.path(), Some(p2.as_path()));
192    /// ```
193    #[must_use]
194    pub fn builder_from_strs_with_paths(
195        string_with_path: &[(String, PathBuf)],
196    ) -> SqlDocBuilder<'_> {
197        SqlDocBuilder {
198            source: SqlFileDocSource::FromStringsWithPaths(string_with_path),
199            deny: Vec::new(),
200            multiline_flat: MultiFlatten::default(),
201            leading_type: LeadingCommentCapture::default(),
202        }
203    }
204
205    /// Method for finding a specific [`TableDoc`] by `name`
206    ///
207    /// # Parameters
208    /// - the table `name` as a [`str`]
209    /// - the table schema as `Option` of [`str`]
210    ///
211    /// # Errors
212    /// - Will return [`DocError::TableNotFound`] if the expected table is not found
213    /// - Will return [`DocError::TableWithSchemaNotFound`] if the table name exists but no table matches the given schema
214    /// - Will return [`DocError::DuplicateTablesFound`] if more than one matching table is found
215    pub fn table(&self, name: &str, schema: Option<&str>) -> Result<&TableDoc, DocError> {
216        let tables = self.tables();
217        let start = tables.partition_point(|n| n.name() < name);
218        if start == tables.len() || tables[start].name() != name {
219            return Err(DocError::TableNotFound { name: name.to_owned() });
220        }
221        let end = tables.partition_point(|t| t.name() <= name);
222        match &tables[start..end] {
223            [single] => Ok(single),
224            multiple => {
225                let mut schemas = multiple.iter().filter(|v| v.schema() == schema);
226                let first = schemas.next().ok_or_else(|| DocError::TableWithSchemaNotFound {
227                    name: name.to_owned(),
228                    schema: schema.map_or_else(
229                        || "No schema provided".to_owned(),
230                        std::borrow::ToOwned::to_owned,
231                    ),
232                })?;
233                if schemas.next().is_some() {
234                    return Err(DocError::DuplicateTablesFound {
235                        tables: multiple
236                            .iter()
237                            .filter(|v| v.schema() == schema)
238                            .map(std::borrow::ToOwned::to_owned)
239                            .collect(),
240                    });
241                }
242                Ok(first)
243            }
244        }
245    }
246
247    /// Getter method for returning the `&[TableDoc]`
248    #[must_use]
249    pub fn tables(&self) -> &[TableDoc] {
250        &self.tables
251    }
252    /// Getter that returns a mutable reference to the [`TableDoc`]
253    #[must_use]
254    pub fn tables_mut(&mut self) -> &mut [TableDoc] {
255        &mut self.tables
256    }
257    /// Method to move tables out of Structure if needed
258    #[must_use]
259    pub fn into_tables(self) -> Vec<TableDoc> {
260        self.tables
261    }
262
263    /// Returns the number of [`TableDoc`]
264    #[must_use]
265    pub fn number_of_tables(&self) -> usize {
266        self.tables().len()
267    }
268}
269
270impl FromStr for SqlDoc {
271    type Err = DocError;
272
273    fn from_str(s: &str) -> Result<Self, Self::Err> {
274        Self::builder_from_str(s).build()
275    }
276}
277
278/// Builder structure for the [`SqlDoc`]
279#[derive(Debug, Eq, PartialEq)]
280pub struct SqlDocBuilder<'a> {
281    /// The source for implementing the [`SqlDoc`] to be built
282    source: SqlFileDocSource<'a>,
283    /// The list of Paths to be ignored for parsing purposes.
284    deny: Vec<String>,
285    /// Tracks the chosen setting for flattening multiline comments
286    multiline_flat: MultiFlatten<'a>,
287    /// Tracks the chosen setting for leading comment collection
288    leading_type: LeadingCommentCapture,
289}
290
291/// Enum for specifying a file doc source as a `directory` or a specific `file`
292#[derive(Debug, Eq, PartialEq)]
293enum SqlFileDocSource<'a> {
294    Dir(PathBuf),
295    File(PathBuf),
296    Files(Vec<PathBuf>),
297    FromString(&'a str),
298    FromStringsWithPaths(&'a [(String, PathBuf)]),
299}
300
301impl<'a> SqlDocBuilder<'a> {
302    /// Method for adding an item to the deny list
303    ///
304    /// # Parameters
305    /// - The `path` that will be added to deny path `Vec`
306    #[must_use]
307    pub fn deny(mut self, deny_path: &str) -> Self {
308        self.deny.push(deny_path.into());
309        self
310    }
311
312    /// Flattens the multiline comments without additional formatting
313    #[must_use]
314    pub const fn flatten_multiline(mut self) -> Self {
315        self.multiline_flat = MultiFlatten::FlattenWithNone;
316        self
317    }
318
319    /// Flattens the multiline comments with [`String`] containing additional leading line formatting to add, such as punctuation.
320    #[must_use]
321    pub const fn flatten_multiline_with(mut self, suffix: &'a str) -> Self {
322        self.multiline_flat = MultiFlatten::Flatten(suffix);
323        self
324    }
325    /// Preserves multiline comments line structure
326    #[must_use]
327    pub const fn preserve_multiline(mut self) -> Self {
328        self.multiline_flat = MultiFlatten::NoFlat;
329        self
330    }
331
332    /// Collects only the comment on preceding lines
333    #[must_use]
334    pub const fn collect_single_nearest(mut self) -> Self {
335        self.leading_type = LeadingCommentCapture::SingleNearest;
336        self
337    }
338
339    /// Collects All valid comments on preceding lines
340    #[must_use]
341    pub const fn collect_all_leading(mut self) -> Self {
342        self.leading_type = LeadingCommentCapture::AllLeading;
343        self
344    }
345
346    /// Collects all single line comments and at most one multiline comment
347    #[must_use]
348    pub const fn collect_all_single_one_multi(mut self) -> Self {
349        self.leading_type = LeadingCommentCapture::AllSingleOneMulti;
350        self
351    }
352
353    /// Builds the [`SqlDoc`]
354    ///
355    ///
356    /// Comment flattening (if enabled) is applied as a post-processing step after docs are generated.
357    ///
358    /// # Errors
359    /// - Will return `DocError` bubbled up
360    pub fn build(self) -> Result<SqlDoc, DocError> {
361        let docs: Vec<SqlFileDoc> = match &self.source {
362            SqlFileDocSource::Dir(path) => {
363                generate_docs_from_dir(path, &self.deny, self.leading_type, self.multiline_flat)?
364            }
365            SqlFileDocSource::File(file) => {
366                let sql_doc =
367                    generate_docs_from_file(file, self.leading_type, self.multiline_flat)?;
368                vec![sql_doc]
369            }
370            SqlFileDocSource::FromString(content) => {
371                let sql_docs =
372                    generate_docs_str(content, None, self.leading_type, self.multiline_flat)?;
373                vec![sql_docs]
374            }
375            SqlFileDocSource::FromStringsWithPaths(strings_paths) => {
376                generate_docs_from_strs_with_paths(
377                    strings_paths,
378                    self.leading_type,
379                    self.multiline_flat,
380                )?
381            }
382            SqlFileDocSource::Files(files) => {
383                generate_docs_from_files(files, self.leading_type, self.multiline_flat)?
384            }
385        };
386        let num_of_tables = docs.iter().map(super::docs::SqlFileDoc::number_of_tables).sum();
387        let mut tables = Vec::with_capacity(num_of_tables);
388        for sql_doc in docs {
389            tables.extend(sql_doc);
390        }
391        let sql_doc = SqlDoc::new(tables);
392        Ok(sql_doc)
393    }
394}
395
396fn generate_docs_from_dir<P: AsRef<Path>, S: AsRef<str>>(
397    source: P,
398    deny: &[S],
399    capture: LeadingCommentCapture,
400    flatten: MultiFlatten,
401) -> Result<Vec<SqlFileDoc>, DocError> {
402    let deny_list: Vec<String> = deny.iter().map(|file| file.as_ref().to_owned()).collect();
403    let file_set = SqlFiles::new(source, &deny_list)?;
404    let mut sql_docs = Vec::new();
405    for file in file_set.sql_files() {
406        let docs = generate_docs_from_file(file, capture, flatten)?;
407        sql_docs.push(docs);
408    }
409    Ok(sql_docs)
410}
411
412fn generate_docs_from_files(
413    files: &[PathBuf],
414    capture: LeadingCommentCapture,
415    flatten: MultiFlatten,
416) -> Result<Vec<SqlFileDoc>, DocError> {
417    let mut sql_docs = Vec::new();
418    for file in files {
419        let docs = generate_docs_from_file(file, capture, flatten)?;
420        sql_docs.push(docs);
421    }
422    Ok(sql_docs)
423}
424
425fn generate_docs_from_file<P: AsRef<Path>>(
426    source: P,
427    capture: LeadingCommentCapture,
428    flatten: MultiFlatten,
429) -> Result<SqlFileDoc, DocError> {
430    let file = SqlSource::from_path(source.as_ref())?;
431    let parsed_file = ParsedSqlFile::parse(file)?;
432    let comments = Comments::parse_all_comments_from_file(&parsed_file)?;
433    let docs = SqlFileDoc::from_parsed_file(&parsed_file, &comments, capture, flatten)?;
434    Ok(docs)
435}
436
437fn generate_docs_str(
438    content: &str,
439    path: Option<PathBuf>,
440    capture: LeadingCommentCapture,
441    flatten: MultiFlatten,
442) -> Result<SqlFileDoc, DocError> {
443    let dummy_file = SqlSource::from_str(content.to_owned(), path);
444    let parsed_sql = ParsedSqlFile::parse(dummy_file)?;
445    let comments = Comments::parse_all_comments_from_file(&parsed_sql)?;
446    let docs = SqlFileDoc::from_parsed_file(&parsed_sql, &comments, capture, flatten)?;
447    Ok(docs)
448}
449
450fn generate_docs_from_strs_with_paths(
451    strings_with_paths: &[(String, PathBuf)],
452    capture: LeadingCommentCapture,
453    flatten: MultiFlatten,
454) -> Result<Vec<SqlFileDoc>, DocError> {
455    let mut docs = Vec::new();
456    for (content, path) in strings_with_paths {
457        docs.push(generate_docs_str(content, Some(path.to_owned()), capture, flatten)?);
458    }
459
460    Ok(docs)
461}
462
463#[cfg(test)]
464mod tests {
465    use std::{
466        env, fs,
467        path::{Path, PathBuf},
468        vec,
469    };
470
471    use crate::{
472        SqlDoc,
473        comments::LeadingCommentCapture,
474        docs::{ColumnDoc, TableDoc},
475        error::DocError,
476        sql_doc::{MultiFlatten, SqlDocBuilder},
477    };
478
479    #[test]
480    fn build_sql_doc_from_file() -> Result<(), Box<dyn std::error::Error>> {
481        let base = env::temp_dir().join("build_sql_doc_from_file");
482        let _ = fs::remove_dir_all(&base);
483        fs::create_dir_all(&base)?;
484        let file = base.join("test_file.sql");
485        let sample = sample_sql();
486        let (contents, expected): (Vec<_>, Vec<_>) = sample.into_iter().unzip();
487        fs::write(&file, contents.join(""))?;
488        let sql_doc = SqlDoc::from_path(&file).build()?;
489        let mut expected_tables: Vec<TableDoc> =
490            expected.into_iter().flat_map(SqlDoc::into_tables).collect();
491        stamp_table_paths(&mut expected_tables, &file);
492        let expected_doc = SqlDoc::new(expected_tables);
493        assert_eq!(sql_doc, expected_doc);
494        let names: Vec<&str> =
495            sql_doc.tables().iter().map(super::super::docs::TableDoc::name).collect();
496        let mut sorted = names.clone();
497        sorted.sort_unstable();
498        assert_eq!(names, sorted, "tables should be in alphabetical order");
499        let _ = fs::remove_dir_all(&base);
500        Ok(())
501    }
502    #[test]
503    fn build_sql_doc_from_dir() -> Result<(), Box<dyn std::error::Error>> {
504        let base = env::temp_dir().join("build_sql_doc_from_dir");
505        let _ = fs::remove_dir_all(&base);
506        fs::create_dir_all(&base)?;
507        let mut expected: Vec<TableDoc> = Vec::new();
508        for (idx, (contents, doc)) in sample_sql().into_iter().enumerate() {
509            let path = base.join(format!("test_file{idx}.sql"));
510            fs::write(&path, contents)?;
511            let mut tables = doc.into_tables();
512            stamp_table_paths(&mut tables, &path);
513            expected.extend(tables);
514        }
515        let sql_doc = SqlDoc::from_dir(&base).build()?;
516        let mut actual: Vec<TableDoc> = sql_doc.into_tables();
517        assert_eq!(actual.len(), expected.len());
518        sort_tables(&mut actual);
519        sort_tables(&mut expected);
520        assert_eq!(actual, expected);
521        let _ = fs::remove_dir_all(&base);
522        Ok(())
523    }
524
525    #[test]
526    fn test_retrieve_table_and_schema() -> Result<(), Box<dyn std::error::Error>> {
527        let base = env::temp_dir().join("build_sql_doc_with_schema");
528        let _ = fs::remove_dir_all(&base);
529        fs::create_dir_all(&base)?;
530        let file = base.join("test_file.sql");
531        let sample = sample_sql();
532        let (contents, expected): (Vec<_>, Vec<_>) = sample.into_iter().unzip();
533        fs::write(&file, contents.join(""))?;
534        let sql_doc = SqlDoc::from_path(&file).build()?;
535        let mut expected_tables: Vec<TableDoc> =
536            expected.into_iter().flat_map(SqlDoc::into_tables).collect();
537        stamp_table_paths(&mut expected_tables, &file);
538        let expected_doc = SqlDoc::new(expected_tables);
539        assert_eq!(sql_doc, expected_doc);
540        let table = "users";
541        assert_eq!(sql_doc.table(table, None)?, expected_doc.table(table, None)?);
542        let schema = "analytics";
543        let schema_table = "events";
544        assert_eq!(
545            sql_doc.table(schema_table, Some(schema))?,
546            expected_doc.table(schema_table, Some(schema))?
547        );
548        let _ = fs::remove_dir_all(&base);
549        Ok(())
550    }
551
552    #[test]
553    fn test_table_err() {
554        let empty_set = SqlDoc::new(vec![]);
555        let empty_table_err = empty_set.table("name", None);
556        assert!(empty_table_err.is_err());
557        assert!(matches!(
558            empty_table_err,
559            Err(DocError::TableNotFound { name }) if name == "name"
560        ));
561    }
562
563    #[test]
564    fn test_schema_err() {
565        let empty_set = SqlDoc::new(vec![]);
566        let empty_table_err = empty_set.table("name", Some("schema"));
567        assert!(empty_table_err.is_err());
568        assert!(matches!(
569            empty_table_err,
570            Err(DocError::TableNotFound { name }) if name == "name"
571        ));
572        let duplicate_set = SqlDoc::new(vec![
573            TableDoc::new(Some("schema".to_owned()), "duplicate".to_owned(), None, vec![], None),
574            TableDoc::new(Some("schema".to_owned()), "duplicate".to_owned(), None, vec![], None),
575        ]);
576        let duplicate_tables_err = duplicate_set.table("duplicate", Some("schema"));
577        assert!(matches!(duplicate_tables_err, Err(DocError::DuplicateTablesFound { .. })));
578    }
579
580    fn sort_tables(tables: &mut [TableDoc]) {
581        tables.sort_by(|a, b| {
582            let a_key = (a.schema().unwrap_or(""), a.name());
583            let b_key = (b.schema().unwrap_or(""), b.name());
584            a_key.cmp(&b_key)
585        });
586    }
587
588    fn stamp_table_paths(tables: &mut [TableDoc], path: &Path) {
589        let pb = path.to_path_buf();
590        for t in tables {
591            t.set_path(Some(pb.clone()));
592        }
593    }
594
595    fn sample_sql() -> Vec<(&'static str, SqlDoc)> {
596        vec![
597            (
598                r"
599            -- Users table
600            CREATE TABLE users (
601                -- id
602                id INTEGER PRIMARY KEY,
603                -- login name
604                username TEXT NOT NULL
605            );
606            ",
607                SqlDoc::new(vec![TableDoc::new(
608                    None,
609                    "users".to_owned(),
610                    Some("Users table".to_owned()),
611                    vec![
612                        ColumnDoc::new("id".to_owned(), Some("id".to_owned())),
613                        ColumnDoc::new("username".to_owned(), Some("login name".to_owned())),
614                    ],
615                    None,
616                )]),
617            ),
618            (
619                r"
620            /* Posts table */
621            CREATE TABLE posts (
622                /* primary key */
623                id INTEGER PRIMARY KEY,
624                title TEXT NOT NULL
625            );
626            ",
627                SqlDoc::new(vec![TableDoc::new(
628                    None,
629                    "posts".to_owned(),
630                    Some("Posts table".to_owned()),
631                    vec![
632                        ColumnDoc::new("id".to_owned(), Some("primary key".to_owned())),
633                        ColumnDoc::new("title".to_owned(), None),
634                    ],
635                    None,
636                )]),
637            ),
638            (
639                r"
640            CREATE TABLE things (
641                id INTEGER PRIMARY KEY,
642                name TEXT,
643                value INTEGER
644            );
645            ",
646                SqlDoc::new(vec![TableDoc::new(
647                    None,
648                    "things".to_owned(),
649                    None,
650                    vec![
651                        ColumnDoc::new("id".to_owned(), None),
652                        ColumnDoc::new("name".to_owned(), None),
653                        ColumnDoc::new("value".to_owned(), None),
654                    ],
655                    None,
656                )]),
657            ),
658            (
659                r"
660            -- Table with schema
661            CREATE TABLE analytics.events (
662                /* event id */
663                id INTEGER PRIMARY KEY,
664                /* event payload */
665                payload TEXT
666            );
667            ",
668                SqlDoc::new(vec![TableDoc::new(
669                    Some("analytics".to_owned()),
670                    "events".to_owned(),
671                    Some("Table with schema".to_owned()),
672                    vec![
673                        ColumnDoc::new("id".to_owned(), Some("event id".to_owned())),
674                        ColumnDoc::new("payload".to_owned(), Some("event payload".to_owned())),
675                    ],
676                    None,
677                )]),
678            ),
679        ]
680    }
681
682    #[test]
683    fn test_sql_doc_getters() {
684        let tables = vec![TableDoc::new(None, "name".to_owned(), None, vec![], None)];
685        let sql_doc = SqlDoc::new(vec![TableDoc::new(None, "name".to_owned(), None, vec![], None)]);
686        assert_eq!(sql_doc.number_of_tables(), tables.len());
687        assert_eq!(sql_doc.tables(), tables);
688    }
689
690    #[test]
691    fn test_sql_builder_deny_from_path() {
692        let actual_builder = SqlDoc::from_path("path").deny("path1").deny("path2");
693        let expected_builder = SqlDocBuilder {
694            source: crate::sql_doc::SqlFileDocSource::File(PathBuf::from("path")),
695            deny: vec!["path1".to_owned(), "path2".to_owned()],
696            multiline_flat: MultiFlatten::default(),
697            leading_type: LeadingCommentCapture::default(),
698        };
699        assert_eq!(actual_builder, expected_builder);
700    }
701
702    #[test]
703    fn test_sql_builder_to_sql_doc() -> Result<(), Box<dyn std::error::Error>> {
704        let base = env::temp_dir().join("sql_builder_to_sql_doc");
705        let _ = fs::remove_dir_all(&base);
706        fs::create_dir_all(&base)?;
707        let file = base.join("test_file.sql");
708        let sample = sample_sql();
709        let (contents, expected): (Vec<_>, Vec<_>) = sample.into_iter().unzip();
710        fs::write(&file, contents.join(""))?;
711        let sql_doc = SqlDoc::from_path(&file).build()?;
712        let deny_str =
713            file.to_str().unwrap_or_else(|| panic!("expected a file from PathBuf Found None"));
714        let sql_doc_deny = SqlDoc::from_dir(&base).deny(deny_str).build()?;
715        let mut expected_tables: Vec<TableDoc> =
716            expected.into_iter().flat_map(SqlDoc::into_tables).collect();
717        stamp_table_paths(&mut expected_tables, &file);
718        let expected_doc = SqlDoc::new(expected_tables);
719        assert_eq!(sql_doc, expected_doc);
720        assert_eq!(sql_doc_deny, SqlDoc::new(vec![]));
721        let _ = fs::remove_dir_all(&base);
722        Ok(())
723    }
724
725    #[test]
726    fn test_builder_multiflatten_variants() {
727        let b1 = SqlDoc::from_path("dummy.sql");
728        let b2 = SqlDoc::from_path("dummy.sql").flatten_multiline();
729        let b3 = SqlDoc::from_path("dummy.sql").flatten_multiline_with(" . ");
730        let b4 = SqlDoc::from_path("dummy.sql").flatten_multiline_with("--").preserve_multiline();
731        assert!(matches!(b1, SqlDocBuilder { multiline_flat: MultiFlatten::NoFlat, .. }));
732        assert!(matches!(b2, SqlDocBuilder { multiline_flat: MultiFlatten::FlattenWithNone, .. }));
733        assert!(
734            matches!(b3, SqlDocBuilder { multiline_flat: MultiFlatten::Flatten(s) , .. } if s == " . ")
735        );
736        assert!(matches!(b4, SqlDocBuilder { multiline_flat: MultiFlatten::NoFlat, .. }));
737    }
738
739    #[test]
740    fn test_preserve_multiline_keeps_newlines_in_docs() -> Result<(), Box<dyn std::error::Error>> {
741        let sql = r"
742        /* Table Doc line1
743           line2 */
744        CREATE TABLE things (
745            /* col1
746               doc */
747            id INTEGER
748        );
749    ";
750
751        let built = SqlDoc::builder_from_str(sql).preserve_multiline().build()?;
752
753        let t = built.table("things", None)?;
754        assert_eq!(t.doc(), Some("Table Doc line1\nline2"));
755        assert_eq!(t.columns()[0].doc(), Some("col1\ndoc"));
756        Ok(())
757    }
758
759    #[test]
760    fn test_flatten_multiline_no_separator_removes_newlines()
761    -> Result<(), Box<dyn std::error::Error>> {
762        let sql = r"
763        /* A
764           B
765           C */
766        CREATE TABLE t (
767            /* x
768               y */
769            c INTEGER
770        );
771    ";
772
773        let built = SqlDoc::builder_from_str(sql).flatten_multiline().build()?;
774
775        let t = built.table("t", None)?;
776        assert_eq!(t.doc(), Some("ABC"));
777        assert_eq!(t.columns()[0].doc(), Some("xy"));
778        Ok(())
779    }
780
781    #[test]
782    fn test_flatten_multiline_with_separator_inserts_separator()
783    -> Result<(), Box<dyn std::error::Error>> {
784        let sql = r"
785        /* hello
786           world */
787        CREATE TABLE t (
788            /* x
789               y
790               z */
791            c INTEGER
792        );
793    ";
794
795        let built = SqlDoc::builder_from_str(sql).flatten_multiline_with(" | ").build()?;
796        dbg!(&built);
797        let t = built.table("t", None)?;
798        assert_eq!(t.doc(), Some("hello | world"));
799        assert_eq!(t.columns()[0].doc(), Some("x | y | z"));
800        Ok(())
801    }
802
803    #[test]
804    fn test_tables_mut_allows_modification() {
805        let mut sql_doc =
806            SqlDoc::new(vec![TableDoc::new(None, "t".into(), Some("old".into()), vec![], None)]);
807        for t in sql_doc.tables_mut() {
808            t.set_doc("new");
809        }
810        assert_eq!(sql_doc.tables()[0].doc(), Some("new"));
811    }
812
813    #[test]
814    fn test_builder_build_with_flattening() -> Result<(), Box<dyn std::error::Error>> {
815        let sql = r"
816        /* Table Doc line1
817           line2 */
818        CREATE TABLE things (
819            /* col1
820               doc */
821            id INTEGER
822        );
823    ";
824
825        let built1 = SqlDoc::builder_from_str(sql).flatten_multiline_with(" • ").build()?;
826        let built2 = SqlDoc::builder_from_str(sql).flatten_multiline().build()?;
827
828        let t1 = built1.table("things", None)?;
829        let t2 = built2.table("things", None)?;
830
831        assert_eq!(t1.doc(), Some("Table Doc line1 • line2"));
832        assert_eq!(t1.columns()[0].doc(), Some("col1 • doc"));
833
834        assert_eq!(t2.doc(), Some("Table Doc line1line2"));
835        assert_eq!(t2.columns()[0].doc(), Some("col1doc"));
836
837        Ok(())
838    }
839
840    #[test]
841    fn test_sql_doc_from_str_builds_expected_builder() {
842        let content = "CREATE TABLE t(id INTEGER);";
843
844        let actual = SqlDoc::builder_from_str(content);
845
846        let expected = SqlDocBuilder {
847            source: crate::sql_doc::SqlFileDocSource::FromString(content),
848            deny: vec![],
849            multiline_flat: MultiFlatten::default(),
850            leading_type: LeadingCommentCapture::default(),
851        };
852
853        assert_eq!(actual, expected);
854    }
855
856    #[test]
857    fn test_from_str_parse_sql_doc() -> Result<(), Box<dyn std::error::Error>> {
858        let doc: SqlDoc = "CREATE TABLE t(id INTEGER);".parse()?;
859        assert_eq!(doc.tables().len(), 1);
860        Ok(())
861    }
862
863    #[test]
864    fn test_build_sql_doc_from_paths() -> Result<(), Box<dyn std::error::Error>> {
865        let base = env::temp_dir().join("build_sql_doc_from_paths");
866        let _ = fs::remove_dir_all(&base);
867        fs::create_dir_all(&base)?;
868        let sample = sample_sql();
869        let (sql1, doc1) = &sample[0];
870        let (sql2, doc2) = &sample[1];
871
872        let file1 = base.join("one.sql");
873        let file2 = base.join("two.sql");
874        fs::write(&file1, sql1)?;
875        fs::write(&file2, sql2)?;
876
877        let paths = vec![file1.clone(), file2.clone()];
878        let sql_doc = SqlDoc::from_paths(&paths).build()?;
879
880        let mut expected_tables: Vec<TableDoc> = Vec::new();
881
882        let mut t1 = doc1.clone().into_tables();
883        stamp_table_paths(&mut t1, &file1);
884        expected_tables.extend(t1);
885
886        let mut t2 = doc2.clone().into_tables();
887        stamp_table_paths(&mut t2, &file2);
888        expected_tables.extend(t2);
889
890        let mut actual_tables = sql_doc.into_tables();
891        assert_eq!(actual_tables.len(), expected_tables.len());
892
893        sort_tables(&mut actual_tables);
894        sort_tables(&mut expected_tables);
895
896        assert_eq!(actual_tables, expected_tables);
897
898        let _ = fs::remove_dir_all(&base);
899        Ok(())
900    }
901
902    #[test]
903    fn test_tables_binary_searchable_by_name() {
904        let sample = sample_sql();
905        let tables: Vec<TableDoc> =
906            sample.into_iter().flat_map(|(_, doc)| doc.into_tables()).collect();
907        let sql_doc = SqlDoc::new(tables);
908        let id = sql_doc
909            .tables()
910            .binary_search_by(|t| t.name().cmp("users"))
911            .unwrap_or_else(|_| panic!("expected to find table `users` via binary search"));
912        assert_eq!(sql_doc.tables()[id].name(), "users");
913    }
914
915    #[test]
916    fn test_table_with_schema_not_found_when_name_exists() {
917        let sql_doc = SqlDoc::new(vec![
918            TableDoc::new(Some("analytics".to_owned()), "events".to_owned(), None, vec![], None),
919            TableDoc::new(Some("public".to_owned()), "events".to_owned(), None, vec![], None),
920        ]);
921
922        match sql_doc.table("events", Some("missing")) {
923            Err(DocError::TableWithSchemaNotFound { name, schema })
924                if name == "events" && schema == "missing" => {}
925            Err(e) => panic!("expected TableWithSchemaNotFound(events, missing), got: {e:?}"),
926            Ok(_) => panic!("expected error, got Ok"),
927        }
928    }
929
930    #[test]
931    fn test_table_duplicate_tables_found_for_same_name_and_schema() {
932        let sql_doc = SqlDoc::new(vec![
933            TableDoc::new(Some("analytics".to_owned()), "events".to_owned(), None, vec![], None),
934            TableDoc::new(Some("analytics".to_owned()), "events".to_owned(), None, vec![], None),
935        ]);
936
937        match sql_doc.table("events", Some("analytics")) {
938            Err(DocError::DuplicateTablesFound { .. }) => {}
939            Err(e) => panic!("expected DuplicateTablesFound, got: {e:?}"),
940            Ok(_) => panic!("expected error, got Ok"),
941        }
942    }
943
944    #[test]
945    fn test_table_selects_correct_schema_when_multiple_exist()
946    -> Result<(), Box<dyn std::error::Error>> {
947        let sql_doc = SqlDoc::new(vec![
948            TableDoc::new(Some("analytics".to_owned()), "events".to_owned(), None, vec![], None),
949            TableDoc::new(Some("public".to_owned()), "events".to_owned(), None, vec![], None),
950        ]);
951
952        let t = sql_doc.table("events", Some("public"))?;
953        assert_eq!(t.schema(), Some("public"));
954        Ok(())
955    }
956    #[test]
957    fn test_generate_docs_from_strs_with_paths_builds_tables_and_stamps_paths()
958    -> Result<(), Box<dyn std::error::Error>> {
959        // Two simple SQL strings with distinct paths
960        let sql1 = "
961            -- Users table
962            CREATE TABLE users (
963                -- id
964                id INTEGER PRIMARY KEY
965            );
966        ";
967
968        let sql2 = "
969            /* Posts table */
970            CREATE TABLE posts (
971                /* primary key */
972                id INTEGER PRIMARY KEY
973            );
974        ";
975
976        let p1 = PathBuf::from("a/one.sql");
977        let p2 = PathBuf::from("b/two.sql");
978
979        // NOTE: builder expects owned String for sql and a PathBuf
980        let inputs: Vec<(String, PathBuf)> =
981            vec![(sql1.to_owned(), p1.clone()), (sql2.to_owned(), p2.clone())];
982
983        // Build via the new builder arm
984        let doc = SqlDoc::builder_from_strs_with_paths(&inputs).build()?;
985
986        // We should have 2 tables total
987        assert_eq!(doc.tables().len(), 2);
988
989        // Verify table names exist
990        let users = doc.table("users", None)?;
991        let posts = doc.table("posts", None)?;
992
993        // Verify each table got the correct stamped path
994        assert_eq!(users.path(), Some(p1.as_path()));
995        assert_eq!(posts.path(), Some(p2.as_path()));
996
997        Ok(())
998    }
999
1000    #[test]
1001    fn test_builder_from_strs_with_paths_is_used_in_build_match_arm()
1002    -> Result<(), Box<dyn std::error::Error>> {
1003        let sql_a = "CREATE TABLE alpha (id INTEGER);";
1004        let sql_b = "CREATE TABLE beta (id INTEGER);";
1005        let path_a = PathBuf::from("alpha.sql");
1006        let path_b = PathBuf::from("beta.sql");
1007
1008        let inputs = vec![(sql_a.to_owned(), path_a.clone()), (sql_b.to_owned(), path_b.clone())];
1009
1010        let built = SqlDoc::builder_from_strs_with_paths(&inputs).build()?;
1011
1012        let names: Vec<&str> =
1013            built.tables().iter().map(super::super::docs::TableDoc::name).collect();
1014        assert_eq!(names, vec!["alpha", "beta"]);
1015
1016        assert_eq!(built.table("alpha", None)?.path(), Some(path_a.as_path()));
1017        assert_eq!(built.table("beta", None)?.path(), Some(path_b.as_path()));
1018
1019        Ok(())
1020    }
1021
1022    #[test]
1023    fn test_builder_from_str_no_path_has_none_path() -> Result<(), Box<dyn std::error::Error>> {
1024        let sql = "CREATE TABLE t (id INTEGER);";
1025        let built = SqlDoc::builder_from_str(sql).build()?;
1026
1027        let t = built.table("t", None)?;
1028        assert_eq!(t.path(), None);
1029
1030        Ok(())
1031    }
1032    #[test]
1033    fn test_table_with_schema_not_found_uses_no_schema_provided_message() {
1034        use crate::{SqlDoc, docs::TableDoc, error::DocError};
1035
1036        let sql_doc = SqlDoc::new(vec![
1037            TableDoc::new(Some("analytics".to_owned()), "events".to_owned(), None, vec![], None),
1038            TableDoc::new(Some("public".to_owned()), "events".to_owned(), None, vec![], None),
1039        ]);
1040
1041        match sql_doc.table("events", None) {
1042            Err(DocError::TableWithSchemaNotFound { name, schema }) => {
1043                assert_eq!(name, "events");
1044                assert_eq!(schema, "No schema provided");
1045            }
1046            Err(e) => {
1047                panic!("expected TableWithSchemaNotFound with 'No schema provided', got: {e:?}")
1048            }
1049            Ok(_) => panic!("expected error, got Ok"),
1050        }
1051    }
1052}