use core::fmt::Write;
use alloc::{format, string::String, vec::Vec};
use crate::{
stream::{FmtWriter, StreamError, Writes},
types::{Coda, Text, Type, Unspecified},
};
pub fn generate_types(coda: &Coda, stream: &mut impl Writes) -> Result<(), StreamError> {
let mut writer = FmtWriter::from(stream);
for (ordinal, typing) in [Unspecified::DATA_TYPE]
.iter()
.chain(coda.iter())
.enumerate()
{
let data_type_name = typing.name.trim();
let mut columns: Vec<(String, Option<Text>)> = Vec::new();
for field in typing.iter() {
let field_name = &field.name;
let mut column = format!(" \"{field_name}\" {}", sqlite_type(&field.typing));
if !field.optional {
column.push_str(" NOT NULL");
}
if let Some(check) = sqlite_check(field_name, &field.typing) {
column.push_str(&format!(" CHECK ({check})"));
}
columns.push((column, sqlite_comment(&field.typing)));
}
let _ = writeln!(writer);
let _ = writeln!(writer, "-- Data {ordinal}.");
if columns.is_empty() {
let _ = writeln!(
writer,
"-- Data with no specified fields has no table; store it as Coda-encoded BLOBs where needed."
);
continue;
}
let _ = writeln!(writer, "CREATE TABLE IF NOT EXISTS \"{data_type_name}\" (");
let last = columns.len() - 1;
for (i, (column, comment)) in columns.iter().enumerate() {
let separator = if i == last { "" } else { "," };
match comment {
Some(comment) => {
let _ = writeln!(writer, "{column}{separator} -- {comment}");
}
None => {
let _ = writeln!(writer, "{column}{separator}");
}
}
}
let _ = writeln!(writer, ") STRICT;");
}
Ok(())
}
fn sqlite_type(typing: &Type) -> Text {
match typing {
Type::U8
| Type::U16
| Type::U32
| Type::U64
| Type::I8
| Type::I16
| Type::I32
| Type::I64
| Type::Bool => Text::Static("INTEGER"),
Type::F32 | Type::F64 => Text::Static("REAL"),
Type::Text => Text::Static("TEXT"),
Type::Unspecified | Type::Array(..) | Type::Data(_) | Type::List(_) | Type::Map(_) => {
Text::Static("BLOB")
}
}
}
fn sqlite_check(name: &Text, typing: &Type) -> Option<Text> {
match typing {
Type::Array(..) => {
let bytes = match typing.format() {
crate::codec::Format::Blob(size) => size,
_ => unreachable!("arrays always have blob formats"),
};
Some(format!("length(\"{name}\") = {bytes}").into())
}
Type::Bool => Some(format!("\"{name}\" IN (0, 1)").into()),
_ => None,
}
}
fn sqlite_comment(typing: &Type) -> Option<Text> {
match typing {
Type::Text => None,
Type::Array(_, elem) if **elem == Type::U8 => Some(coda_type_name(typing)),
Type::Array(..) => {
Some(format!("{} (raw little-endian bytes)", coda_type_name(typing)).into())
}
Type::List(elem) if **elem == Type::U8 => Some(coda_type_name(typing)),
Type::Unspecified | Type::Data(_) | Type::List(_) | Type::Map(_) => {
Some(format!("Coda-encoded {}", coda_type_name(typing)).into())
}
_ => Some(coda_type_name(typing)),
}
}
fn coda_type_name(typing: &Type) -> Text {
match typing {
Type::Unspecified => Text::Static("unspecified"),
Type::U8 => Text::Static("u8"),
Type::U16 => Text::Static("u16"),
Type::U32 => Text::Static("u32"),
Type::U64 => Text::Static("u64"),
Type::I8 => Text::Static("i8"),
Type::I16 => Text::Static("i16"),
Type::I32 => Text::Static("i32"),
Type::I64 => Text::Static("i64"),
Type::F32 => Text::Static("f32"),
Type::F64 => Text::Static("f64"),
Type::Bool => Text::Static("bool"),
Type::Text => Text::Static("text"),
Type::Array(count, elem) => format!("array of {count} {}", coda_type_name(elem)).into(),
Type::Data(typing) => typing.name.clone(),
Type::List(elem) => format!("list of {}", coda_type_name(elem)).into(),
Type::Map(typing) => format!(
"map of {} to {}",
coda_type_name(&typing.0),
coda_type_name(&typing.1)
)
.into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parse::{parse, tests::TEST_CODA_MARKDOWN};
#[test]
fn smoke() {
let coda = parse(TEST_CODA_MARKDOWN).unwrap();
let mut sql = Vec::new();
generate_types(&coda, &mut sql).unwrap();
let sql = String::from_utf8_lossy(&sql);
assert_eq!(
r#"
-- Data 0.
-- Data with no specified fields has no table; store it as Coda-encoded BLOBs where needed.
-- Data 1.
CREATE TABLE IF NOT EXISTS "MyNestedDataType" (
"floaty_field" REAL NOT NULL, -- f32
"listy_field" BLOB NOT NULL -- Coda-encoded list of text
) STRICT;
-- Data 2.
CREATE TABLE IF NOT EXISTS "MyDataType" (
"integral_field" INTEGER NOT NULL, -- i32
"array_field" BLOB NOT NULL CHECK (length("array_field") = 16), -- array of 16 u8
"matrix_field" BLOB NOT NULL CHECK (length("matrix_field") = 36), -- array of 3 array of 3 f32 (raw little-endian bytes)
"textual_field" TEXT NOT NULL,
"nested_field" BLOB NOT NULL, -- Coda-encoded MyNestedDataType
"optional_field" INTEGER, -- u64
"3d_field" BLOB NOT NULL, -- Coda-encoded list of list of list of i32
"map_field" BLOB NOT NULL, -- Coda-encoded map of text to i32
"unspecified_field" BLOB NOT NULL, -- Coda-encoded unspecified
"frames_field" BLOB NOT NULL -- Coda-encoded list of array of 3 f32
) STRICT;"#
.trim(),
sql.trim()
);
}
#[test]
fn optional_arrays_are_nullable_with_checks() {
let coda = parse(
"# `Test` Coda\n\n## `Data` Data\n\n+ `sig` optional array of 64 u8\n\n+ `flag` bool\n\n+ `payload` list of u8\n",
)
.unwrap();
let mut sql = Vec::new();
generate_types(&coda, &mut sql).unwrap();
let sql = String::from_utf8_lossy(&sql);
assert!(
sql.contains("\"sig\" BLOB CHECK (length(\"sig\") = 64), -- array of 64 u8"),
"unexpected sql: {sql}"
);
assert!(
sql.contains("\"flag\" INTEGER NOT NULL CHECK (\"flag\" IN (0, 1)), -- bool"),
"unexpected sql: {sql}"
);
assert!(
sql.contains("\"payload\" BLOB NOT NULL -- list of u8"),
"unexpected sql: {sql}"
);
}
}