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