use std::fmt::Write;
use scythe_backend::manifest::BackendManifest;
use scythe_backend::naming::{composite_type_name, enum_type_name, field_name, to_pascal_case};
use scythe_backend::types::{resolve_docblock_type, resolve_type, resolve_type_pair};
use scythe_core::analyzer::{CompositeInfo, NestedStructInfo};
use scythe_core::errors::{ErrorCode, ScytheError};
use crate::GeneratedCode;
use crate::backend_trait::{ResolvedColumn, ResolvedParam};
use crate::nested_struct_shape;
pub(crate) const RECORD_NOT_FOUND_EXCEPTION_CLASS: &str = "RecordNotFoundException";
pub(crate) fn record_not_found_exception_class_def() -> String {
format!("final class {RECORD_NOT_FOUND_EXCEPTION_CLASS} extends \\RuntimeException {{}}\n")
}
fn narrower_docblock_type(
neutral: &str,
nullable: bool,
native: &str,
name: &str,
manifest: &BackendManifest,
) -> Result<Option<String>, ScytheError> {
let rendered = resolve_docblock_type(neutral, manifest, nullable).map_err(|e| {
ScytheError::new(
ErrorCode::InternalError,
format!("docblock type resolution failed for '{name}': {e}"),
)
})?;
if rendered == native {
return Ok(None);
}
Ok(Some(rendered.into_owned()))
}
pub(crate) fn write_promoted_property(
out: &mut String,
column: &ResolvedColumn,
manifest: &BackendManifest,
) -> Result<(), ScytheError> {
if let Some(docblock) = narrower_docblock_type(
&column.neutral_type,
column.nullable,
&column.full_type,
&column.name,
manifest,
)? {
let _ = writeln!(out, " /** @var {} */", docblock);
}
let _ = writeln!(out, " public {} ${},", column.full_type, column.field_name);
Ok(())
}
pub(crate) fn param_docblock_type(param: &ResolvedParam, manifest: &BackendManifest) -> Result<String, ScytheError> {
let narrower = narrower_docblock_type(
¶m.neutral_type,
param.nullable,
¶m.full_type,
¶m.name,
manifest,
)?;
Ok(narrower.unwrap_or_else(|| param.full_type.clone()))
}
fn resolved_json_field(
name: &str,
neutral_type: &str,
nullable: bool,
manifest: &BackendManifest,
) -> Result<ResolvedColumn, ScytheError> {
let (full_type, lang_type) = if neutral_type == "decimal" {
let scalar = "float".to_string();
let full = if nullable { format!("?{scalar}") } else { scalar.clone() };
Ok((full, scalar))
} else {
resolve_type_pair(neutral_type, manifest, nullable).map(|(full, lang)| (full.into_owned(), lang.into_owned()))
}
.map_err(|error| {
ScytheError::new(
ErrorCode::InternalError,
format!("nested JSON field type resolution failed for '{name}': {error}"),
)
})?;
Ok(ResolvedColumn {
name: name.to_string(),
field_name: field_name(name, &manifest.naming).into_owned(),
lang_type,
full_type,
neutral_type: neutral_type.to_string(),
nullable,
join_group: None,
nullable_before_join: nullable,
sql_type: String::new(),
})
}
fn php_json_value_expr(neutral_type: &str, lang_type: &str, raw: &str) -> String {
if neutral_type.starts_with("composite::") {
return format!("{lang_type}::fromJson({raw})");
}
if neutral_type.starts_with("enum::") {
return format!("{lang_type}::from({raw})");
}
if neutral_type.contains('<') || lang_type == "array" {
return raw.to_string();
}
if neutral_type == "decimal" {
return format!("(float) {raw}");
}
match lang_type {
"int" => format!("(int) {raw}"),
"float" => format!("(float) {raw}"),
"bool" => format!("(bool) {raw}"),
"string" => format!("(string) {raw}"),
"\\DateTimeImmutable" => format!("new \\DateTimeImmutable({raw})"),
_ => raw.to_string(),
}
}
fn php_json_field_expr(column: &ResolvedColumn, raw: &str) -> String {
let value = php_json_value_expr(&column.neutral_type, &column.lang_type, raw);
if column.nullable {
format!("{raw} !== null ? {value} : null")
} else {
value
}
}
pub(crate) fn generate_nested_struct_def(
nested: &NestedStructInfo,
manifest: &BackendManifest,
) -> Result<String, ScytheError> {
let name = to_pascal_case(&nested.name);
let fields = nested
.fields
.iter()
.map(|field| resolved_json_field(&field.name, &field.neutral_type, field.nullable, manifest))
.collect::<Result<Vec<_>, _>>()?;
let mut out = String::new();
let _ = writeln!(out, "readonly class {name} {{");
let _ = writeln!(out, " public function __construct(");
for field in &fields {
if field.neutral_type == "decimal" {
let _ = writeln!(out, " public {} ${},", field.full_type, field.field_name);
} else {
write_promoted_property(&mut out, field, manifest)?;
}
}
let _ = writeln!(out, " ) {{}}");
let _ = writeln!(out);
let _ = writeln!(out, " public static function fromJson(array $value): self {{");
let _ = writeln!(out, " return new self(");
for field in &fields {
let raw = format!("$value['{}']", field.name);
let value = php_json_field_expr(field, &raw);
let _ = writeln!(out, " {}: {},", field.field_name, value);
}
let _ = writeln!(out, " );");
let _ = writeln!(out, " }}");
let _ = write!(out, "}}");
Ok(out)
}
pub(crate) fn nested_json_column_expr(column: &ResolvedColumn, raw: &str) -> Option<String> {
let shape = nested_struct_shape(&column.neutral_type)?;
let decoded = format!("json_decode({raw}, true, 512, \\JSON_THROW_ON_ERROR)");
let converted = if shape.is_array {
let item = if shape.element_nullable {
format!(
"static fn (?array $item): ?{} => $item === null ? null : {}::fromJson($item)",
shape.name, shape.name
)
} else {
format!(
"static fn (array $item): {} => {}::fromJson($item)",
shape.name, shape.name
)
};
format!("array_map({item}, {decoded})")
} else {
format!("{}::fromJson({decoded})", shape.name)
};
Some(if column.nullable {
format!("{raw} !== null ? {converted} : null")
} else {
converted
})
}
const PHP_PARSE_COMPOSITE_FIELDS_METHOD: &str = r#" /**
* ~keep Splits a PostgreSQL composite's text form ("(a,b,c)") into its raw field tokens,
* honoring its escaping rules: an empty unquoted field is SQL NULL (returned as null); a
* field needing quoting (containing a comma, paren, quote, backslash, or leading/trailing
* space, or the empty string) is wrapped in double quotes; every other field is unquoted and
* taken literally. A nested composite's own "(x,y)" text form always contains parens, so it
* always comes back quoted here, ready for that type's own fromText to parse recursively.
*
* Inside a quoted field record_out writes a literal '"' as '""' and a literal '\' as '\\'.
* Both spellings must be accepted: reading '""' as "closing quote, then a new field" both
* truncates the value and desynchronizes every field after it. Verified against
* PostgreSQL 16 -- ROW('he said "hi"', 'back\slash', NULL) renders as
* ("he said ""hi""","back\\slash",).
*
* @return array<int, string|null>
*/
public static function parseCompositeFields(string $text): array {
$fields = [];
$inner = substr($text, 1, strlen($text) - 2);
$i = 0;
$n = strlen($inner);
while (true) {
$field = '';
$isNull = false;
if ($i < $n && $inner[$i] === '"') {
$i++;
while ($i < $n) {
$c = $inner[$i];
if ($c === '\\' && $i + 1 < $n) {
$field .= $inner[$i + 1];
$i += 2;
} elseif ($c === '"' && $i + 1 < $n && $inner[$i + 1] === '"') {
$field .= '"';
$i += 2;
} elseif ($c === '"') {
$i++;
break;
} else {
$field .= $c;
$i++;
}
}
} else {
$start = $i;
while ($i < $n && $inner[$i] !== ',') {
$i++;
}
$field = substr($inner, $start, $i - $start);
$isNull = $field === '';
}
$fields[] = $isNull ? null : $field;
if ($i < $n && $inner[$i] === ',') {
$i++;
continue;
}
break;
}
return $fields;
}
"#;
pub(crate) const PHP_COMPOSITE_PARSER_CLASS: &str = "ScytheCompositeText";
fn php_composite_parser_class_def() -> String {
format!("final class {PHP_COMPOSITE_PARSER_CLASS} {{\n{PHP_PARSE_COMPOSITE_FIELDS_METHOD}}}\n")
}
pub(crate) fn composite_parser_class_if_used(generated: &[GeneratedCode]) -> String {
let call = format!("{PHP_COMPOSITE_PARSER_CLASS}::parseCompositeFields(");
let used = generated.iter().any(|code| {
[
code.enum_def.as_deref(),
code.model_struct.as_deref(),
code.row_struct.as_deref(),
code.query_fn.as_deref(),
]
.into_iter()
.flatten()
.any(|fragment| fragment.contains(&call))
|| code.nested_struct_defs.iter().any(|def| def.code.contains(&call))
});
if used {
format!("\n{}", php_composite_parser_class_def())
} else {
String::new()
}
}
fn php_composite_field_from_text(
neutral_type: &str,
field_type: &str,
raw: &str,
nullable: bool,
manifest: &BackendManifest,
) -> String {
let converted = if let Some(sql_name) = neutral_type.strip_prefix("composite::") {
format!("{}::fromText({raw})", composite_type_name(sql_name, &manifest.naming))
} else if let Some(sql_name) = neutral_type.strip_prefix("enum::") {
format!("{}::from({raw})", enum_type_name(sql_name, &manifest.naming))
} else {
match neutral_type {
"bool" => format!("{raw} === 't'"),
"int16" | "int32" | "int64" => format!("(int) {raw}"),
"float32" | "float64" => format!("(float) {raw}"),
"bytes" => format!("hex2bin(substr({raw}, 2))"),
"date" | "time" | "time_tz" | "datetime" | "datetime_tz" => {
format!("new \\DateTimeImmutable({raw})")
}
"json" if field_type == "array" => format!("json_decode({raw}, true)"),
_ => format!("(string) {raw}"),
}
};
if nullable {
format!("{raw} === null ? null : {converted}")
} else {
converted
}
}
fn generate_composite_def_inner(
composite: &CompositeInfo,
manifest: &BackendManifest,
include_json_factory: bool,
) -> Result<String, ScytheError> {
let name = composite_type_name(&composite.sql_name, &manifest.naming);
let mut out = String::new();
if composite.fields.is_empty() {
let _ = writeln!(out, "readonly class {} {{}}", name);
return Ok(out);
}
let field_types: Vec<String> = composite
.fields
.iter()
.map(|f| {
if include_json_factory && f.neutral_type == "decimal" {
return "string|float".to_string();
}
resolve_type(&f.neutral_type, manifest, f.nullable)
.map(|t| t.into_owned())
.unwrap_or_else(|_| "mixed".to_string())
})
.collect();
let _ = writeln!(out, "readonly class {} {{", name);
let _ = writeln!(out, " public function __construct(");
for (field, field_type) in composite.fields.iter().zip(&field_types) {
let _ = writeln!(out, " public {} ${},", field_type, field.name);
}
let _ = writeln!(out, " ) {{}}");
let _ = writeln!(out);
let _ = writeln!(out, " public static function fromText(?string $text): ?self {{");
let _ = writeln!(out, " if ($text === null) {{");
let _ = writeln!(out, " return null;");
let _ = writeln!(out, " }}");
let _ = writeln!(
out,
" $f = {PHP_COMPOSITE_PARSER_CLASS}::parseCompositeFields($text);"
);
let _ = writeln!(out, " return new self(");
for (i, (field, field_type)) in composite.fields.iter().zip(&field_types).enumerate() {
let raw = format!("$f[{}]", i);
let conversion_type = resolve_type(&field.neutral_type, manifest, false)
.map(|t| t.into_owned())
.unwrap_or_else(|_| field_type.clone());
let value_expr =
php_composite_field_from_text(&field.neutral_type, &conversion_type, &raw, field.nullable, manifest);
let _ = writeln!(out, " {},", value_expr);
}
let _ = writeln!(out, " );");
let _ = writeln!(out, " }}");
let encoded_fields = composite
.fields
.iter()
.map(|field| format!("self::encodeCompositeField($this->{})", field.name))
.collect::<Vec<_>>()
.join(", ");
let _ = writeln!(out);
let _ = writeln!(out, " public function toPgText(): string {{");
let _ = writeln!(out, " return '(' . implode(',', [{}]) . ')';", encoded_fields);
let _ = writeln!(out, " }}");
let _ = writeln!(out);
let _ = writeln!(
out,
" private static function encodeCompositeField(mixed $value): string {{"
);
let _ = writeln!(out, " if ($value === null) {{");
let _ = writeln!(out, " return '';");
let _ = writeln!(out, " }}");
let _ = writeln!(
out,
" if (is_object($value) && method_exists($value, 'toPgText')) {{"
);
let _ = writeln!(out, " $raw = $value->toPgText();");
let _ = writeln!(out, " }} elseif ($value instanceof \\BackedEnum) {{");
let _ = writeln!(out, " $raw = (string) $value->value;");
let _ = writeln!(out, " }} elseif ($value instanceof \\DateTimeInterface) {{");
let _ = writeln!(out, " $raw = $value->format('Y-m-d H:i:sP');");
let _ = writeln!(out, " }} else {{");
let _ = writeln!(out, " $raw = (string) $value;");
let _ = writeln!(out, " }}");
let _ = writeln!(
out,
" if ($raw !== '' && strpbrk($raw, ',()\"\\\\') === false && $raw === trim($raw)) {{"
);
let _ = writeln!(out, " return $raw;");
let _ = writeln!(out, " }}");
let _ = writeln!(
out,
" return '\"' . str_replace(['\\\\', '\"'], ['\\\\\\\\', '\"\"'], $raw) . '\"';"
);
let _ = writeln!(out, " }}");
if include_json_factory {
let _ = writeln!(out);
let _ = writeln!(out, " public static function fromJson(array $value): self {{");
let _ = writeln!(out, " return new self(");
for (field, field_type) in composite.fields.iter().zip(&field_types) {
let raw = format!("$value['{}']", field.name);
let value = php_json_value_expr(&field.neutral_type, field_type, &raw);
let _ = writeln!(out, " {},", value);
}
let _ = writeln!(out, " );");
let _ = writeln!(out, " }}");
}
let _ = write!(out, "}}");
Ok(out)
}
pub(crate) fn generate_composite_def(
composite: &CompositeInfo,
manifest: &BackendManifest,
) -> Result<String, ScytheError> {
generate_composite_def_inner(composite, manifest, false)
}
pub(crate) fn generate_nested_composite_def(
composite: &CompositeInfo,
manifest: &BackendManifest,
) -> Result<String, ScytheError> {
generate_composite_def_inner(composite, manifest, true)
}