Skip to main content

foodmart_data/
lib.rs

1// Licensed to Julian Hyde under one or more contributor license
2// agreements.  See the NOTICE file distributed with this work for
3// additional information regarding copyright ownership. Julian Hyde
4// licenses this file to you under the Apache License, Version 2.0 (the
5// "License"); you may not use this file except in compliance with the
6// License.  You may obtain a copy of the License at
7//
8// http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15
16//! The Foodmart data set, as embedded CSV files.
17//!
18//! The data set originated as part of the test suite of the Pentaho
19//! Mondrian OLAP engine. It contains 26 tables: 7 fact tables, such as
20//! `sales_fact_1997`, and 19 dimension tables, such as `customer`.
21//!
22//! [`TABLES`] describes the schema; each [`Table`] carries its own CSV
23//! text, so there are no files to find at run time.
24//!
25//! ```
26//! let table = foodmart_data::find("days").unwrap();
27//! assert_eq!(table.column_names(), ["day", "week_day"]);
28//! assert_eq!(table.rows().count(), 7);
29//! ```
30//!
31//! The crate has no dependencies and does not link a database. To load
32//! the data, generate DDL and DML with [`Table::create_table_sql`] and
33//! [`Table::insert_sql`] and execute them against a database of your
34//! choice.
35
36#![forbid(unsafe_code)]
37#![warn(missing_docs)]
38
39use std::borrow::Cow;
40use std::fmt::Write as _;
41
42mod schema;
43
44pub use schema::TABLES;
45
46/// Value of the `not_null` argument to [`Column::new`], for a column
47/// that may contain nulls.
48pub(crate) const NULL: bool = false;
49
50/// Value of the `not_null` argument to [`Column::new`], for a column
51/// that is declared `NOT NULL`.
52pub(crate) const NOT_NULL: bool = true;
53
54/// A column of a [`Table`].
55#[derive(Clone, Copy, Debug, Eq, PartialEq)]
56pub struct Column {
57    /// The column name, as it appears in the CSV header row.
58    pub name: &'static str,
59    /// The SQL type, for example `"VARCHAR(30)"` or `"DECIMAL(10,4)"`.
60    pub sql_type: &'static str,
61    /// Whether the column is declared `NOT NULL`.
62    pub not_null: bool,
63}
64
65/// A table in the Foodmart data set.
66#[derive(Clone, Copy, Debug, Eq, PartialEq)]
67pub struct Table {
68    /// The table name, and also the base name of its CSV file.
69    pub name: &'static str,
70    /// The table's columns, in CSV column order.
71    pub columns: &'static [Column],
72    /// The contents of the table's CSV file, including the header row.
73    pub csv: &'static str,
74}
75
76/// Returns the names of all tables, in alphabetical order.
77pub fn table_names() -> impl Iterator<Item = &'static str> {
78    TABLES.iter().map(|t| t.name)
79}
80
81/// Returns the table with the given name.
82pub fn find(name: &str) -> Option<&'static Table> {
83    TABLES.iter().find(|t| t.name == name)
84}
85
86impl Table {
87    /// Returns the table's column names, in CSV column order.
88    pub fn column_names(&self) -> Vec<&'static str> {
89        self.columns.iter().map(|c| c.name).collect()
90    }
91
92    /// Returns the header row of the CSV file, as field names.
93    ///
94    /// This is always equal to [`Table::column_names`]; the crate's
95    /// tests check that it is.
96    pub fn header(&self) -> Vec<Cow<'static, str>> {
97        parse_record(self.csv.lines().next().unwrap_or(""))
98    }
99
100    /// Returns the table's data rows. The header row is skipped.
101    ///
102    /// An empty field represents SQL `NULL`, and is yielded as an empty
103    /// string; consult [`Column::not_null`] to tell an empty string
104    /// from a null.
105    pub fn rows(&self) -> Rows {
106        let mut lines = self.csv.lines();
107        lines.next(); // discard the header row
108        Rows { lines }
109    }
110
111    /// Returns a `CREATE TABLE` statement for the table, with
112    /// identifiers quoted using the SQL standard double-quote.
113    ///
114    /// ```
115    /// let table = foodmart_data::find("days").unwrap();
116    /// assert_eq!(
117    ///     table.create_table_sql(),
118    ///     r#"CREATE TABLE "days" ("day" INTEGER NOT NULL, "week_day" VARCHAR(30) NOT NULL)"#);
119    /// ```
120    pub fn create_table_sql(&self) -> String {
121        let mut s = format!("CREATE TABLE \"{}\" (", self.name);
122        for (i, c) in self.columns.iter().enumerate() {
123            if i > 0 {
124                s.push_str(", ");
125            }
126            let _ = write!(s, "\"{}\" {}", c.name, c.sql_type);
127            if c.not_null {
128                s.push_str(" NOT NULL");
129            }
130        }
131        s.push(')');
132        s
133    }
134
135    /// Returns an `INSERT` statement for one row, as literal SQL.
136    ///
137    /// Values of character, date and timestamp columns are quoted;
138    /// numeric and boolean values are not; an empty field becomes
139    /// `NULL`.
140    ///
141    /// # Panics
142    ///
143    /// Panics if `row` does not have one value per column.
144    pub fn insert_sql(&self, row: &[Cow<'_, str>]) -> String {
145        assert_eq!(
146            row.len(),
147            self.columns.len(),
148            "{}: got {} values, want {}",
149            self.name,
150            row.len(),
151            self.columns.len()
152        );
153        let mut s = format!("INSERT INTO \"{}\" VALUES (", self.name);
154        for (i, (c, v)) in self.columns.iter().zip(row).enumerate() {
155            if i > 0 {
156                s.push_str(", ");
157            }
158            if v.is_empty() {
159                s.push_str("NULL");
160            } else if c.is_character() {
161                let _ = write!(s, "'{}'", v.replace('\'', "''"));
162            } else {
163                s.push_str(v);
164            }
165        }
166        s.push(')');
167        s
168    }
169}
170
171impl Column {
172    /// Creates a column, given its name, its SQL type, and whether it
173    /// is declared `NOT NULL`.
174    #[must_use]
175    pub const fn new(
176        name: &'static str,
177        sql_type: &'static str,
178        not_null: bool,
179    ) -> Self {
180        Self {
181            name,
182            sql_type,
183            not_null,
184        }
185    }
186
187    /// Returns the SQL type without its precision, for example
188    /// `"DECIMAL"` given `"DECIMAL(10,4)"`.
189    pub fn base_type(&self) -> &'static str {
190        match self.sql_type.find('(') {
191            Some(i) => &self.sql_type[..i],
192            None => self.sql_type,
193        }
194    }
195
196    /// Returns whether values of this column need quoting in SQL.
197    pub fn is_character(&self) -> bool {
198        matches!(self.base_type(), "VARCHAR" | "CHAR" | "DATE" | "TIMESTAMP")
199    }
200}
201
202/// An iterator over the data rows of a [`Table`], created by
203/// [`Table::rows`].
204#[derive(Clone, Debug)]
205pub struct Rows {
206    lines: std::str::Lines<'static>,
207}
208
209impl Iterator for Rows {
210    type Item = Vec<Cow<'static, str>>;
211
212    fn next(&mut self) -> Option<Self::Item> {
213        loop {
214            let line = self.lines.next()?;
215            if !line.is_empty() {
216                return Some(parse_record(line));
217            }
218        }
219    }
220}
221
222/// Splits one line of CSV into fields.
223///
224/// Follows RFC 4180: a field may be enclosed in double quotes, within
225/// which a doubled double-quote denotes a single one. A field that
226/// needs no unescaping borrows from the input rather than allocating.
227fn parse_record(line: &str) -> Vec<Cow<'_, str>> {
228    let mut fields = Vec::new();
229    let bytes = line.as_bytes();
230    let mut i = 0;
231    loop {
232        if i < bytes.len() && bytes[i] == b'"' {
233            // A quoted field. Scan for the closing quote, treating a
234            // doubled quote as an escaped one.
235            let mut value = String::new();
236            let mut start = i + 1;
237            let mut j = start;
238            while j < bytes.len() {
239                if bytes[j] == b'"' {
240                    if bytes.get(j + 1) == Some(&b'"') {
241                        value.push_str(&line[start..j + 1]);
242                        j += 2;
243                        start = j;
244                        continue;
245                    }
246                    break;
247                }
248                j += 1;
249            }
250            value.push_str(&line[start..j.min(bytes.len())]);
251            fields.push(Cow::Owned(value));
252            // Skip the closing quote and the following comma, if any.
253            i = (j + 1).min(bytes.len());
254        } else {
255            let j = bytes[i..]
256                .iter()
257                .position(|&b| b == b',')
258                .map_or(bytes.len(), |k| i + k);
259            fields.push(Cow::Borrowed(&line[i..j]));
260            i = j;
261        }
262        if i >= bytes.len() {
263            return fields;
264        }
265        debug_assert_eq!(bytes[i], b',');
266        i += 1;
267        if i == bytes.len() {
268            // A trailing comma means a final empty field.
269            fields.push(Cow::Borrowed(""));
270            return fields;
271        }
272    }
273}
274
275// End lib.rs