use std::path::PathBuf;
use proc_macro::TokenStream;
use sha2::{Digest, Sha256};
use syn::parse::{Parse, ParseStream};
use syn::{LitStr, Token};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ServerDb {
Postgres,
None,
}
pub(super) struct ServerSchemaArgs {
pub(super) schema_path: LitStr,
pub(super) db: ServerDb,
}
impl Parse for ServerSchemaArgs {
fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
let schema_path: LitStr = input.parse()?;
input.parse::<Token![,]>()?;
let key: syn::Ident = input.parse()?;
if key != "db" {
return Err(syn::Error::new(
key.span(),
"expected `db = Postgres` (only the `db` argument is recognised)",
));
}
input.parse::<Token![=]>()?;
let value: syn::Ident = input.parse()?;
let db = match value.to_string().as_str() {
"Postgres" => ServerDb::Postgres,
"None" => ServerDb::None,
other => {
return Err(syn::Error::new(
value.span(),
format!(
"unsupported db backend `{other}`. supported: Postgres, None. (MySql / sqlite-via-sqlx will land in a future release.)"
),
));
}
};
Ok(Self { schema_path, db })
}
}
pub(super) fn parse_schema_literal(
schema_path: &LitStr,
) -> Result<(String, PathBuf, cratestack_core::Schema, String), TokenStream> {
let schema_relative = schema_path.value();
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
let resolved = PathBuf::from(&manifest_dir).join(&schema_relative);
let source = std::fs::read_to_string(&resolved).map_err(|error| {
TokenStream::from(
syn::Error::new(
schema_path.span(),
format!("failed to read schema file {}: {error}", resolved.display()),
)
.to_compile_error(),
)
})?;
let schema = cratestack_parser::parse_schema_named(&resolved.display().to_string(), &source)
.map_err(|error| {
TokenStream::from(
syn::Error::new(
schema_path.span(),
error.render(&resolved.display().to_string(), &source),
)
.to_compile_error(),
)
})?;
reject_composite_primary_keys(schema_path, &schema)?;
let schema_sha256 = hash_schema_source(&source);
Ok((schema_relative, resolved, schema, schema_sha256))
}
pub(super) fn hash_schema_source(source: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(source.as_bytes());
format!("{:x}", hasher.finalize())
}
fn reject_composite_primary_keys(
schema_path: &LitStr,
schema: &cratestack_core::Schema,
) -> Result<(), TokenStream> {
if let Some(model) = find_composite_id_model(schema) {
return Err(TokenStream::from(
syn::Error::new(
schema_path.span(),
format!(
"model `{}` declares a composite primary key via `@@id([...])`, which is not yet supported by codegen (query builders, routing, and generated clients still assume a single scalar `@id`); see https://github.com/cratestack/cratestack/issues/136 for status",
model.name,
),
)
.to_compile_error(),
));
}
Ok(())
}
fn find_composite_id_model(schema: &cratestack_core::Schema) -> Option<&cratestack_core::Model> {
schema
.models
.iter()
.find(|model| model.attributes.iter().any(|a| a.raw.starts_with("@@id(")))
}
#[cfg(test)]
mod tests {
use super::{find_composite_id_model, hash_schema_source};
#[test]
fn hash_schema_source_matches_a_known_sha256() {
assert_eq!(
hash_schema_source("model Widget { id Int @id }"),
"50fa300ea14f963f4573be7bfff0fb95b58d728f2431afbecb43578370af6e3e"
);
}
#[test]
fn hash_schema_source_is_deterministic_and_content_sensitive() {
let a = hash_schema_source("model A { id Int @id }");
let b = hash_schema_source("model A { id Int @id }");
let c = hash_schema_source("model B { id Int @id }");
assert_eq!(a, b);
assert_ne!(a, c);
}
#[test]
fn flags_model_with_composite_id_attribute() {
let schema = cratestack_parser::parse_schema(
r#"
model AccountMembership {
accountId Int
subject String
@@id([accountId, subject])
}
"#,
)
.expect("schema should parse");
let flagged = find_composite_id_model(&schema);
assert_eq!(
flagged.map(|model| model.name.as_str()),
Some("AccountMembership")
);
}
#[test]
fn does_not_flag_single_field_id() {
let schema = cratestack_parser::parse_schema(
r#"
model Account {
id Int @id
}
"#,
)
.expect("schema should parse");
assert!(find_composite_id_model(&schema).is_none());
}
}