use std::collections::HashMap;
use std::fmt::Write;
use scythe_backend::manifest::BackendManifest;
use scythe_backend::naming::{
composite_type_name, enum_type_name, enum_variant_name, fn_name, to_camel_case, to_pascal_case,
};
use scythe_backend::types::resolve_type;
use scythe_core::SqlDialect;
use scythe_core::analyzer::{AnalyzedQuery, CompositeInfo, EnumInfo};
use scythe_core::errors::{ErrorCode, ScytheError};
use scythe_core::parser::QueryCommand;
use crate::backend_options::reject_unknown_options;
use crate::backend_trait::{CodegenBackend, GroupedQueryFn, ResolvedColumn, ResolvedParam};
use crate::backends::jvm_common;
const DEFAULT_MANIFEST_PG: &str = include_str!("../../manifests/kotlin-exposed.toml");
pub struct KotlinExposedBackend {
manifest: BackendManifest,
}
impl KotlinExposedBackend {
pub fn new(engine: &str) -> Result<Self, ScytheError> {
let default_toml = match engine {
"postgresql" | "postgres" | "pg" => DEFAULT_MANIFEST_PG,
_ => {
return Err(ScytheError::new(
ErrorCode::InvalidConfig,
format!("unsupported engine '{}' for kotlin-exposed backend", engine),
));
}
};
let manifest = super::parse_manifest(default_toml)?;
Ok(Self { manifest })
}
}
fn exposed_column_fn(kotlin_type: &str) -> &str {
match kotlin_type {
"Boolean" => "bool",
"Byte" => "byte",
"Short" => "short",
"Int" => "integer",
"Long" => "long",
"Float" => "float",
"Double" => "double",
"String" => "text",
"ByteArray" => "binary",
_ if kotlin_type.contains("BigDecimal") => "decimal",
_ if kotlin_type.contains("LocalDate") => "date",
_ if kotlin_type.contains("LocalTime") => "time",
_ if kotlin_type.contains("OffsetTime") => "time",
_ if kotlin_type.contains("LocalDateTime") => "datetime",
_ if kotlin_type.contains("OffsetDateTime") => "timestampWithTimeZone",
_ if kotlin_type.contains("UUID") => "uuid",
_ => "text",
}
}
fn element_kotlin_type(element_neutral: &str, manifest: &BackendManifest) -> String {
resolve_type(element_neutral, manifest, false)
.map(|t| t.into_owned())
.unwrap_or_else(|_| "Any".to_string())
}
fn array_list_expr(sql_array_expr: &str, element_type: &str) -> String {
format!("({sql_array_expr}.array as Array<*>).map {{ it as {element_type} }}")
}
const KT_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 with `"` and
* `\` backslash-escaped inside; 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.
*/
private fun parseCompositeFields(text: String): List<String?> {
val fields = mutableListOf<String?>()
val inner = text.substring(1, text.length - 1)
var i = 0
val n = inner.length
while (true) {
val field = StringBuilder()
var isNull = false
if (i < n && inner[i] == '"') {
i++
while (i < n) {
val c = inner[i]
if (c == '\\' && i + 1 < n) {
field.append(inner[i + 1])
i += 2
} else if (c == '"' && i + 1 < n && inner[i + 1] == '"') {
field.append('"')
i += 2
} else if (c == '"') {
i++
break
} else {
field.append(c)
i++
}
}
} else {
val start = i
while (i < n && inner[i] != ',') {
i++
}
field.append(inner, start, i)
isNull = field.isEmpty()
}
fields.add(if (isNull) null else field.toString())
if (i < n && inner[i] == ',') {
i++
continue
}
break
}
return fields
}
"#;
const KT_PARSE_COMPOSITE_BYTES_METHOD: &str = r#" /**
* ~keep PostgreSQL's default `bytea` text output is hex: "\x48656c6c6f". Decode the hex
* digits after the "\x" prefix back into bytes.
*/
private fun parseCompositeBytes(hex: String): ByteArray {
val digits = hex.substring(2)
val result = ByteArray(digits.length / 2)
for (i in result.indices) {
result[i] = digits.substring(i * 2, i * 2 + 2).toInt(16).toByte()
}
return result
}
"#;
const KT_PARSE_COMPOSITE_OFFSET_DATETIME_METHOD: &str = r#" /**
* ~keep PostgreSQL's default `timestamptz` text output uses a space instead of `T`
* ("2024-01-15 10:30:00+00") and omits the offset's minutes when they are zero ("+00"
* rather than "+00:00"). Normalize both before parsing.
*/
private fun parseCompositeOffsetDateTime(raw: String): java.time.OffsetDateTime {
var s = raw.replace(' ', 'T')
val sign = s[s.length - 3]
if (sign == '+' || sign == '-') {
s += ":00"
}
return java.time.OffsetDateTime.parse(s)
}
"#;
const KT_PARSE_COMPOSITE_OFFSET_TIME_METHOD: &str = r#" /**
* ~keep PostgreSQL's default `timetz` text output omits the offset's minutes when they
* are zero ("13:22:43-05" rather than "13:22:43-05:00"), which `OffsetTime.parse` rejects.
*/
private fun parseCompositeOffsetTime(raw: String): java.time.OffsetTime {
var s = raw
val sign = s[s.length - 3]
if (sign == '+' || sign == '-') {
s += ":00"
}
return java.time.OffsetTime.parse(s)
}
"#;
fn composite_needs_bytes_helper(composite: &CompositeInfo) -> bool {
composite.fields.iter().any(|f| f.neutral_type == "bytes")
}
fn composite_needs_offset_datetime_helper(composite: &CompositeInfo) -> bool {
composite.fields.iter().any(|f| f.neutral_type == "datetime_tz")
}
fn composite_needs_offset_time_helper(composite: &CompositeInfo) -> bool {
composite.fields.iter().any(|f| f.neutral_type == "time_tz")
}
fn composite_field_from_text_kotlin(
neutral_type: &str,
field_type: &str,
raw: &str,
manifest: &BackendManifest,
) -> String {
if let Some(sql_name) = neutral_type.strip_prefix("composite::") {
return format!(
"{}.fromText({})!!",
composite_type_name(sql_name, &manifest.naming),
raw
);
}
if neutral_type.starts_with("enum::") {
return format!("{}.fromValue({})", field_type, raw);
}
match neutral_type {
"bool" => format!("{} == \"t\"", raw),
"int16" => format!("{}.toShort()", raw),
"int32" => format!("{}.toInt()", raw),
"int64" => format!("{}.toLong()", raw),
"float32" => format!("{}.toFloat()", raw),
"float64" => format!("{}.toDouble()", raw),
"decimal" => format!("java.math.BigDecimal({})", raw),
"uuid" => format!("java.util.UUID.fromString({})", raw),
"date" => format!("java.time.LocalDate.parse({})", raw),
"time" => format!("java.time.LocalTime.parse({})", raw),
"datetime" => format!("java.time.LocalDateTime.parse({}.replace(' ', 'T'))", raw),
"datetime_tz" => format!("parseCompositeOffsetDateTime({})", raw),
"time_tz" => format!("parseCompositeOffsetTime({})", raw),
"bytes" => format!("parseCompositeBytes({})", raw),
_ => raw.to_string(),
}
}
fn exposed_rs_expr(col: &ResolvedColumn, manifest: &BackendManifest) -> String {
if col.neutral_type.starts_with("composite::") {
let unwrap = if col.nullable { "" } else { "!!" };
return format!("{}.fromText(rs.getString(\"{}\")){}", col.lang_type, col.name, unwrap);
}
if col.nullable {
return col.field_name.clone();
}
if let Some(element) = jvm_common::array_element_neutral_type(&col.neutral_type) {
let element_type = element_kotlin_type(element, manifest);
return array_list_expr(&format!("rs.getArray(\"{}\")", col.name), &element_type);
}
if jvm_common::is_enum_column(&col.neutral_type) {
return format!("{}.fromValue(rs.getString(\"{}\"))", col.lang_type, col.name);
}
jvm_common::kotlin_jdbc_read_call(&col.name, &col.lang_type)
}
fn write_exposed_nullable_preamble(
out: &mut String,
cols: &[ResolvedColumn],
indent: &str,
manifest: &BackendManifest,
) {
for col in cols {
if !col.nullable {
continue;
}
if col.neutral_type.starts_with("composite::") {
continue;
}
if let Some(element) = jvm_common::array_element_neutral_type(&col.neutral_type) {
let element_type = element_kotlin_type(element, manifest);
let _ = writeln!(
out,
"{}val {}SqlArray = rs.getArray(\"{}\")",
indent, col.field_name, col.name
);
let list_expr = array_list_expr(&format!("{}SqlArray", col.field_name), &element_type);
let _ = writeln!(
out,
"{}val {} = if ({}SqlArray == null) null else {}",
indent, col.field_name, col.field_name, list_expr
);
continue;
}
if jvm_common::is_enum_column(&col.neutral_type) {
let _ = writeln!(
out,
"{}val {}Value = rs.getString(\"{}\")",
indent, col.field_name, col.name
);
let _ = writeln!(
out,
"{}val {} = if ({}Value == null) null else {}.fromValue({}Value)",
indent, col.field_name, col.field_name, col.lang_type, col.field_name
);
continue;
}
let _ = writeln!(
out,
"{}val {}Value = {}",
indent,
col.field_name,
jvm_common::kotlin_jdbc_read_call(&col.name, &col.lang_type)
);
let _ = writeln!(
out,
"{}val {} = if (rs.wasNull()) null else {}Value",
indent, col.field_name, col.field_name
);
}
}
fn exposed_column_type_class(kotlin_type: &str) -> &str {
match kotlin_type {
"Boolean" => "BooleanColumnType()",
"Byte" => "ByteColumnType()",
"Short" => "ShortColumnType()",
"Int" => "IntegerColumnType()",
"Long" => "LongColumnType()",
"Float" => "FloatColumnType()",
"Double" => "DoubleColumnType()",
"String" => "TextColumnType()",
"ByteArray" => "BinaryColumnType()",
_ if kotlin_type.contains("BigDecimal") => "DecimalColumnType(10, 2)",
_ if kotlin_type.contains("LocalDate") => "JavaLocalDateColumnType()",
_ if kotlin_type.contains("LocalTime") => "JavaLocalTimeColumnType()",
_ if kotlin_type.contains("OffsetTime") => "JavaLocalTimeColumnType()",
_ if kotlin_type.contains("LocalDateTime") => "JavaLocalDateTimeColumnType()",
_ if kotlin_type.contains("OffsetDateTime") => "JavaOffsetDateTimeColumnType()",
_ if kotlin_type.contains("UUID") => "UUIDColumnType()",
_ => "TextColumnType()",
}
}
fn exposed_bind_value(param: &ResolvedParam) -> String {
exposed_bind_value_for(param, ¶m.field_name)
}
fn exposed_bind_value_for(param: &ResolvedParam, receiver: &str) -> String {
if param.neutral_type.starts_with("enum::") {
format!("{receiver}?.value")
} else if param.neutral_type.starts_with("composite::") {
format!("{receiver}?.toPgText()")
} else {
receiver.to_string()
}
}
impl CodegenBackend for KotlinExposedBackend {
fn name(&self) -> &str {
"kotlin-exposed"
}
fn manifest(&self) -> &scythe_backend::manifest::BackendManifest {
&self.manifest
}
fn manifest_mut(&mut self) -> &mut scythe_backend::manifest::BackendManifest {
&mut self.manifest
}
fn apply_options(&mut self, options: &HashMap<String, String>) -> Result<(), ScytheError> {
reject_unknown_options(&["field_case"], options)?;
if let Some(value) = options.get("field_case") {
super::apply_field_case_option(&mut self.manifest.naming, "kotlin-exposed", value)?;
}
Ok(())
}
fn supported_engines(&self) -> &[&str] {
&["postgresql"]
}
fn file_header(&self) -> String {
"package generated\n\n\
import org.jetbrains.exposed.dao.id.IntIdTable\n\
import org.jetbrains.exposed.dao.id.LongIdTable\n\
import org.jetbrains.exposed.dao.id.UUIDTable\n\
import org.jetbrains.exposed.sql.BinaryColumnType\n\
import org.jetbrains.exposed.sql.BooleanColumnType\n\
import org.jetbrains.exposed.sql.ByteColumnType\n\
import org.jetbrains.exposed.sql.DecimalColumnType\n\
import org.jetbrains.exposed.sql.IColumnType\n\
import org.jetbrains.exposed.sql.DoubleColumnType\n\
import org.jetbrains.exposed.sql.FloatColumnType\n\
import org.jetbrains.exposed.sql.IntegerColumnType\n\
import org.jetbrains.exposed.sql.LongColumnType\n\
import org.jetbrains.exposed.sql.ShortColumnType\n\
import org.jetbrains.exposed.sql.TextColumnType\n\
import org.jetbrains.exposed.sql.UUIDColumnType\n\
import org.jetbrains.exposed.sql.javatime.JavaLocalDateColumnType\n\
import org.jetbrains.exposed.sql.javatime.JavaLocalDateTimeColumnType\n\
import org.jetbrains.exposed.sql.javatime.JavaLocalTimeColumnType\n\
import org.jetbrains.exposed.sql.javatime.JavaOffsetDateTimeColumnType\n\
import org.jetbrains.exposed.sql.statements.StatementType\n\
import org.jetbrains.exposed.sql.transactions.transaction\n"
.to_string()
}
fn generate_struct_decl(
&self,
struct_name: &str,
_query_name: &str,
columns: &[ResolvedColumn],
) -> Result<String, ScytheError> {
let mut out = String::new();
let _ = writeln!(out, "data class {}(", struct_name);
for col in columns.iter() {
let _ = writeln!(out, " val {}: {},", col.field_name, col.full_type);
}
let _ = writeln!(out, ")");
Ok(out)
}
fn generate_model_struct(&self, table_name: &str, columns: &[ResolvedColumn]) -> Result<String, ScytheError> {
let struct_name = crate::model_struct_name(table_name, &self.manifest.naming);
let table_obj_name = format!("{}Table", struct_name);
let mut out = String::new();
let table_base = exposed_id_table_type(columns);
let _ = writeln!(out, "object {} : {}(\"{}\") {{", table_obj_name, table_base, table_name);
for col in columns.iter() {
let col_fn = exposed_column_fn(&col.lang_type);
let nullable_suffix = if col.nullable { ".nullable()" } else { "" };
let _ = writeln!(
out,
" val {} = {}(\"{}\"){}",
col.field_name, col_fn, col.name, nullable_suffix
);
}
let _ = writeln!(out, "}}");
let _ = writeln!(out);
let row_decl = self.generate_struct_decl(&struct_name, &struct_name, columns)?;
out.push_str(&row_decl);
Ok(out)
}
fn generate_query_fn(
&self,
analyzed: &AnalyzedQuery,
struct_name: &str,
columns: &[ResolvedColumn],
params: &[ResolvedParam],
) -> Result<String, ScytheError> {
let func_name = fn_name(&analyzed.name, &self.manifest.naming);
let dialect = SqlDialect::PostgreSQL;
let cleaned_sql = super::clean_sql_oneline_with_optional_dialect(
&analyzed.sql,
dialect,
&analyzed.optional_params,
&analyzed.params,
);
let (rewritten_sql, occurrences) =
super::rewrite_pg_typed_placeholders(&cleaned_sql, &analyzed.params, params, true, |_| "?".to_string());
let sql = crate::sql_literal::escape_kotlin_string(&rewritten_sql);
let mut out = String::new();
let write_fn_sig = |out: &mut String, name: &str, ret: &str, params: &[ResolvedParam]| {
let inline_params: String = params
.iter()
.map(|p| format!("{}: {}", p.field_name, p.full_type))
.collect::<Vec<_>>()
.join(", ");
let sig = format!("fun {}({}){} =", name, inline_params, ret);
if sig.len() <= 100 {
let _ = writeln!(out, "{}", sig);
} else {
let _ = writeln!(out, "fun {}(", name);
for p in params {
let _ = writeln!(out, " {}: {},", p.field_name, p.full_type);
}
let _ = writeln!(out, "){} =", ret);
}
let _ = writeln!(out, " transaction {{");
};
let build_args = |occurrences: &[u32]| -> String {
if occurrences.is_empty() {
return String::new();
}
let pairs: Vec<String> = occurrences
.iter()
.map(|&position| {
let p = super::resolved_param_for_position(&analyzed.params, params, position);
format!(
"{} to {}",
exposed_column_type_class(&p.lang_type),
exposed_bind_value(p)
)
})
.collect();
format!(", listOf<Pair<IColumnType<*>, Any?>>({})", pairs.join(", "))
};
match &analyzed.command {
QueryCommand::Exec => {
write_fn_sig(&mut out, &func_name, "", params);
let args = build_args(&occurrences);
let _ = writeln!(out, " exec(\"{}\"{})", sql, args);
let _ = writeln!(out, " }}");
}
QueryCommand::ExecResult | QueryCommand::ExecRows => {
write_fn_sig(&mut out, &func_name, ": Int", params);
let args = build_args(&occurrences);
let _ = writeln!(
out,
" val stmt = connection.prepareStatement(\"{}\", false)",
sql
);
if !args.is_empty() {
let list = args.trim_start_matches(", ");
let _ = writeln!(out, " stmt.fillParameters({})", list);
}
let _ = writeln!(out, " stmt.executeUpdate()");
let _ = writeln!(out, " }}");
}
QueryCommand::One | QueryCommand::Opt => {
let is_one = matches!(analyzed.command, QueryCommand::One);
let ret = if is_one {
format!(": {}", struct_name)
} else {
format!(": {}?", struct_name)
};
write_fn_sig(&mut out, &func_name, &ret, params);
let args = build_args(&occurrences);
let _ = writeln!(
out,
" exec(\"{}\"{}, explicitStatementType = StatementType.SELECT) {{ rs ->",
sql, args
);
let _ = writeln!(out, " if (rs.next()) {{");
write_exposed_nullable_preamble(&mut out, columns, " ", &self.manifest);
let _ = writeln!(out, " {}(", struct_name);
for col in columns.iter() {
let _ = writeln!(
out,
" {} = {},",
col.field_name,
exposed_rs_expr(col, &self.manifest)
);
}
let _ = writeln!(out, " )");
let _ = writeln!(out, " }} else {{");
let _ = writeln!(out, " null");
let _ = writeln!(out, " }}");
if is_one {
let _ = writeln!(
out,
" }} ?: throw NoSuchElementException(\"{}: no rows returned\")",
func_name
);
} else {
let _ = writeln!(out, " }}");
}
let _ = writeln!(out, " }}");
}
QueryCommand::Batch => {
let batch_fn_name = format!("{}Batch", func_name);
if params.len() > 1 {
let params_class_name = format!("{}BatchParams", to_pascal_case(&analyzed.name));
let _ = writeln!(out, "data class {}(", params_class_name);
for p in params {
let _ = writeln!(out, " val {}: {},", p.field_name, p.full_type);
}
let _ = writeln!(out, ")");
let _ = writeln!(out);
let _ = writeln!(out, "fun {}(items: List<{}>) =", batch_fn_name, params_class_name);
let _ = writeln!(out, " transaction {{");
let _ = writeln!(out, " for (item in items) {{");
let args: Vec<String> = occurrences
.iter()
.map(|&position| {
let p = super::resolved_param_for_position(&analyzed.params, params, position);
let receiver = format!("item.{}", p.field_name);
format!(
"{} to {}",
exposed_column_type_class(&p.lang_type),
exposed_bind_value_for(p, &receiver)
)
})
.collect();
let _ = writeln!(out, " exec(\"{}\", listOf({}))", sql, args.join(", "));
let _ = writeln!(out, " }}");
let _ = writeln!(out, " }}");
} else if params.len() == 1 {
let _ = writeln!(out, "fun {}(items: List<{}>) =", batch_fn_name, params[0].full_type);
let _ = writeln!(out, " transaction {{");
let _ = writeln!(out, " for (item in items) {{");
let args: Vec<String> = occurrences
.iter()
.map(|_| {
format!(
"{} to {}",
exposed_column_type_class(¶ms[0].lang_type),
exposed_bind_value_for(¶ms[0], "item")
)
})
.collect();
let _ = writeln!(out, " exec(\"{}\", listOf({}))", sql, args.join(", "));
let _ = writeln!(out, " }}");
let _ = writeln!(out, " }}");
} else {
let _ = writeln!(out, "fun {}(count: Int) =", batch_fn_name);
let _ = writeln!(out, " transaction {{");
let _ = writeln!(out, " repeat(count) {{");
let _ = writeln!(out, " exec(\"{}\")", sql);
let _ = writeln!(out, " }}");
let _ = writeln!(out, " }}");
}
}
QueryCommand::Grouped => {
unreachable!("routed to generate_grouped_query_fn")
}
QueryCommand::Many => {
let ret = format!(": List<{}>", struct_name);
write_fn_sig(&mut out, &func_name, &ret, params);
let args = build_args(&occurrences);
let _ = writeln!(out, " val result = mutableListOf<{}>()", struct_name);
let _ = writeln!(
out,
" exec(\"{}\"{}, explicitStatementType = StatementType.SELECT) {{ rs ->",
sql, args
);
let _ = writeln!(out, " while (rs.next()) {{");
write_exposed_nullable_preamble(&mut out, columns, " ", &self.manifest);
let _ = writeln!(out, " result.add(");
let _ = writeln!(out, " {}(", struct_name);
for col in columns.iter() {
let _ = writeln!(
out,
" {} = {},",
col.field_name,
exposed_rs_expr(col, &self.manifest)
);
}
let _ = writeln!(out, " ),");
let _ = writeln!(out, " )");
let _ = writeln!(out, " }}");
let _ = writeln!(out, " }}");
let _ = writeln!(out, " result");
let _ = writeln!(out, " }}");
}
}
Ok(out)
}
fn generate_enum_def(&self, enum_info: &EnumInfo) -> Result<String, ScytheError> {
let type_name = enum_type_name(&enum_info.sql_name, &self.manifest.naming);
let mut out = String::new();
let _ = writeln!(out, "enum class {}(val value: String) {{", type_name);
for (i, value) in enum_info.values.iter().enumerate() {
let variant = enum_variant_name(value, &self.manifest.naming);
let sep = if i + 1 < enum_info.values.len() { "," } else { ";" };
let _ = writeln!(out, " {}(\"{}\"){}", variant, value, sep);
}
let _ = writeln!(out);
let _ = writeln!(out, " companion object {{");
let _ = writeln!(out, " fun fromValue(value: String): {} =", type_name);
let _ = writeln!(out, " values().firstOrNull {{ it.value == value }}");
let _ = writeln!(
out,
" ?: throw IllegalArgumentException(\"Unknown {} value: $value\")",
type_name
);
let _ = writeln!(out, " }}");
let _ = writeln!(out, "}}");
Ok(out)
}
fn generate_composite_def(&self, composite: &CompositeInfo) -> Result<String, ScytheError> {
let name = composite_type_name(&composite.sql_name, &self.manifest.naming);
let mut out = String::new();
let field_types: Vec<String> = composite
.fields
.iter()
.map(|f| {
resolve_type(&f.neutral_type, &self.manifest, f.nullable)
.map(|t| t.into_owned())
.unwrap_or_else(|_| "Any".to_string())
})
.collect();
let field_base_types: Vec<String> = composite
.fields
.iter()
.map(|f| {
resolve_type(&f.neutral_type, &self.manifest, false)
.map(|t| t.into_owned())
.unwrap_or_else(|_| "Any".to_string())
})
.collect();
let _ = writeln!(out, "data class {}(", name);
for (field, field_type) in composite.fields.iter().zip(&field_types) {
let field_name = to_camel_case(&field.name);
let _ = writeln!(out, " val {}: {},", field_name, field_type);
}
if composite.fields.is_empty() {
let _ = writeln!(out, ")");
return Ok(out);
}
let _ = writeln!(out, ") {{");
let encoded_fields = composite
.fields
.iter()
.map(|field| {
let field_name = to_camel_case(&field.name);
if field.neutral_type.starts_with("composite::") {
format!("{field_name}?.toPgText()")
} else if field.neutral_type.starts_with("enum::") {
format!("{field_name}?.value")
} else if field.neutral_type == "bytes" {
format!("{field_name}?.joinToString(\"\") {{ \"%02x\".format(it) }}?.let {{ \"\\\\\\\\x\" + it }}")
} else {
field_name.into_owned()
}
})
.collect::<Vec<_>>()
.join(", ");
let _ = writeln!(
out,
" fun toPgText(): String = listOf({encoded_fields}).joinToString(\",\", \"(\", \")\") {{ encodeCompositeField(it) }}"
);
let _ = writeln!(out);
let _ = writeln!(out, " companion object {{");
let _ = writeln!(out, " private fun encodeCompositeField(value: Any?): String {{");
let _ = writeln!(out, " if (value == null) return \"\"");
let _ = writeln!(out, " val raw = value.toString()");
out.push_str(
r#" val quote = raw.isEmpty() || raw.any { it in charArrayOf('(', ')', ',', '"', '\\') } || raw != raw.trim()
"#,
);
let _ = writeln!(out, " if (!quote) return raw");
out.push_str(
r#" return "\"" + raw.replace("\\", "\\\\").replace("\"", "\"\"") + "\""
"#,
);
let _ = writeln!(out, " }}");
let _ = writeln!(out);
let _ = writeln!(out, " /**");
let _ = writeln!(
out,
" * ~keep board #196: pgjdbc registers no `getObject(col, {}::class.java)`",
name
);
let _ = writeln!(
out,
" * type map for this composite -- it throws `PSQLException: conversion to"
);
let _ = writeln!(
out,
" * class {}` at runtime. Parse the driver's composite text form instead.",
name
);
let _ = writeln!(out, " */");
let _ = writeln!(out, " fun fromText(text: String?): {}? {{", name);
let _ = writeln!(out, " if (text == null) {{");
let _ = writeln!(out, " return null");
let _ = writeln!(out, " }}");
let _ = writeln!(out, " val f = parseCompositeFields(text)");
let _ = writeln!(out, " return {}(", name);
for (i, (field, field_type)) in composite.fields.iter().zip(&field_base_types).enumerate() {
let raw = format!("f[{}]", i);
let expr = if field.nullable {
let conversion =
composite_field_from_text_kotlin(&field.neutral_type, field_type, "value", &self.manifest);
format!("{raw}?.let {{ value -> {conversion} }}")
} else {
composite_field_from_text_kotlin(&field.neutral_type, field_type, &format!("{raw}!!"), &self.manifest)
};
let _ = writeln!(out, " {},", expr);
}
let _ = writeln!(out, " )");
let _ = writeln!(out, " }}");
let _ = writeln!(out);
out.push_str(KT_PARSE_COMPOSITE_FIELDS_METHOD);
if composite_needs_bytes_helper(composite) {
let _ = writeln!(out);
out.push_str(KT_PARSE_COMPOSITE_BYTES_METHOD);
}
if composite_needs_offset_datetime_helper(composite) {
let _ = writeln!(out);
out.push_str(KT_PARSE_COMPOSITE_OFFSET_DATETIME_METHOD);
}
if composite_needs_offset_time_helper(composite) {
let _ = writeln!(out);
out.push_str(KT_PARSE_COMPOSITE_OFFSET_TIME_METHOD);
}
let _ = writeln!(out, " }}");
let _ = writeln!(out, "}}");
Ok(out)
}
fn generate_grouped_structs(
&self,
parent_struct_name: &str,
child_struct_name: &str,
parent_columns: &[ResolvedColumn],
child_columns: &[ResolvedColumn],
_key_column: &str,
) -> Result<String, ScytheError> {
let mut out = String::new();
let _ = writeln!(out, "data class {}(", child_struct_name);
for col in child_columns {
let _ = writeln!(out, " val {}: {},", col.field_name, col.full_type);
}
let _ = writeln!(out, ")");
let _ = writeln!(out);
let _ = writeln!(out, "data class {}(", parent_struct_name);
for col in parent_columns {
let _ = writeln!(out, " val {}: {},", col.field_name, col.full_type);
}
let _ = writeln!(out, " val children: MutableList<{}>,", child_struct_name);
let _ = write!(out, ")");
Ok(out)
}
fn generate_grouped_query_fn(&self, request: &GroupedQueryFn<'_>) -> Result<String, ScytheError> {
let analyzed = request.analyzed;
let parent_struct_name = request.parent_struct_name;
let child_struct_name = request.child_struct_name;
let parent_columns = request.parent_columns;
let child_columns = request.child_columns;
let params = request.params;
let key_column = request.key_column;
let func_name = fn_name(&analyzed.name, &self.manifest.naming);
let dialect = SqlDialect::PostgreSQL;
let cleaned_sql = super::clean_sql_oneline_with_optional_dialect(
&analyzed.sql,
dialect,
&analyzed.optional_params,
&analyzed.params,
);
let (rewritten_sql, occurrences) =
super::rewrite_pg_typed_placeholders(&cleaned_sql, &analyzed.params, params, true, |_| "?".to_string());
let sql = crate::sql_literal::escape_kotlin_string(&rewritten_sql);
let key_col = parent_columns
.iter()
.find(|c| c.name == key_column)
.unwrap_or(&parent_columns[0]);
let key_type = key_col.full_type.trim_end_matches('?');
let args = if occurrences.is_empty() {
String::new()
} else {
let pairs: Vec<String> = occurrences
.iter()
.map(|&position| {
let p = super::resolved_param_for_position(&analyzed.params, params, position);
format!(
"{} to {}",
exposed_column_type_class(&p.lang_type),
exposed_bind_value(p)
)
})
.collect();
format!(", listOf<Pair<IColumnType<*>, Any?>>({})", pairs.join(", "))
};
let inline_params: String = params
.iter()
.map(|p| format!("{}: {}", p.field_name, p.full_type))
.collect::<Vec<_>>()
.join(", ");
let sig = format!("fun {}({}): List<{parent_struct_name}> =", func_name, inline_params);
let mut out = String::new();
if sig.len() <= 100 {
let _ = writeln!(out, "{sig}");
} else {
let _ = writeln!(out, "fun {}(", func_name);
for p in params {
let _ = writeln!(out, " {}: {},", p.field_name, p.full_type);
}
let _ = writeln!(out, "): List<{parent_struct_name}> =");
}
let _ = writeln!(out, " transaction {{");
let _ = writeln!(
out,
" val lookup = LinkedHashMap<{key_type}, {parent_struct_name}>()"
);
let _ = writeln!(out, " val result = mutableListOf<{parent_struct_name}>()");
let _ = writeln!(out, " exec(\"{sql}\"{args}) {{ rs ->");
let _ = writeln!(out, " while (rs.next()) {{");
write_exposed_nullable_preamble(&mut out, child_columns, " ", &self.manifest);
write_exposed_nullable_preamble(&mut out, parent_columns, " ", &self.manifest);
let key_expr = exposed_rs_expr(key_col, &self.manifest);
let _ = writeln!(out, " val key = {key_expr}");
let _ = writeln!(out, " val child = {child_struct_name}(");
for col in child_columns {
let _ = writeln!(
out,
" {} = {},",
col.field_name,
exposed_rs_expr(col, &self.manifest)
);
}
let _ = writeln!(out, " )");
let _ = writeln!(out, " if (lookup.containsKey(key)) {{");
let _ = writeln!(out, " lookup[key]!!.children.add(child)");
let _ = writeln!(out, " }} else {{");
let _ = writeln!(out, " val parent = {parent_struct_name}(");
for col in parent_columns {
let _ = writeln!(
out,
" {} = {},",
col.field_name,
exposed_rs_expr(col, &self.manifest)
);
}
let _ = writeln!(out, " children = mutableListOf(child),");
let _ = writeln!(out, " )");
let _ = writeln!(out, " lookup[key] = parent");
let _ = writeln!(out, " result.add(parent)");
let _ = writeln!(out, " }}");
let _ = writeln!(out, " }}");
let _ = writeln!(out, " }}");
let _ = writeln!(out, " result");
let _ = write!(out, " }}");
Ok(out)
}
}
fn exposed_id_table_type(columns: &[ResolvedColumn]) -> &str {
let Some(id_column) = columns.iter().find(|column| column.name.eq_ignore_ascii_case("id")) else {
return "IntIdTable";
};
match id_column.lang_type.as_str() {
"Long" => "LongIdTable",
ty if ty.contains("UUID") => "UUIDTable",
_ => "IntIdTable",
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use scythe_core::analyzer::{
AnalyzedColumn, AnalyzedParam, AnalyzedQuery, CompositeFieldInfo, CompositeInfo, GroupByConfig,
};
use scythe_core::parser::QueryCommand;
use super::KotlinExposedBackend;
use crate::backend_trait::CodegenBackend;
fn make_grouped_query() -> AnalyzedQuery {
let parent_cols = vec![
AnalyzedColumn {
name: "id".to_string(),
neutral_type: "int32".to_string(),
nullable: false,
..Default::default()
},
AnalyzedColumn {
name: "name".to_string(),
neutral_type: "string".to_string(),
nullable: false,
..Default::default()
},
];
let child_cols = vec![
AnalyzedColumn {
name: "order_id".to_string(),
neutral_type: "int32".to_string(),
nullable: false,
..Default::default()
},
AnalyzedColumn {
name: "total".to_string(),
neutral_type: "decimal".to_string(),
nullable: true,
..Default::default()
},
];
let all_cols = [parent_cols.clone(), child_cols.clone()].concat();
AnalyzedQuery::build(|aq| {
aq.name = "GetUsersWithOrders".to_string();
aq.command = QueryCommand::Grouped;
aq.sql = "SELECT u.id, u.name, o.id AS order_id, o.total FROM users u JOIN orders o ON o.user_id = u.id"
.to_string();
aq.columns = all_cols;
aq.params = vec![];
aq.deprecated = None;
aq.source_table = None;
aq.composites = vec![];
aq.enums = vec![];
aq.optional_params = vec![];
aq.group_by = Some(GroupByConfig {
table: "users".to_string(),
key_column: "id".to_string(),
parent_columns: parent_cols,
child_columns: child_cols,
});
aq.custom = vec![];
})
}
#[test]
fn test_grouped_kotlin_exposed_structs() {
let backend = crate::backends::get_backend("kotlin-exposed", "postgresql").unwrap();
let query = make_grouped_query();
let result = crate::generate_with_backend(&query, &*backend).unwrap();
let row_struct = result.row_struct.as_deref().unwrap();
assert!(
row_struct.contains("data class GetUsersWithOrdersChildRow"),
"missing child data class; got:\n{row_struct}"
);
assert!(
row_struct.contains("data class GetUsersWithOrdersRow"),
"missing parent data class; got:\n{row_struct}"
);
assert!(
row_struct.contains("val children: MutableList<GetUsersWithOrdersChildRow>"),
"parent missing children field; got:\n{row_struct}"
);
let child_pos = row_struct.find("data class GetUsersWithOrdersChildRow").unwrap();
let parent_pos = row_struct.find("data class GetUsersWithOrdersRow(").unwrap();
assert!(child_pos < parent_pos, "child must precede parent");
}
#[test]
fn test_grouped_kotlin_exposed_query_fn() {
let backend = crate::backends::get_backend("kotlin-exposed", "postgresql").unwrap();
let query = make_grouped_query();
let result = crate::generate_with_backend(&query, &*backend).unwrap();
let query_fn = result.query_fn.as_deref().unwrap();
assert!(
query_fn.contains("List<GetUsersWithOrdersRow>"),
"wrong return type; got:\n{query_fn}"
);
assert!(
query_fn.contains("transaction {"),
"must use transaction block; got:\n{query_fn}"
);
assert!(
query_fn.contains("LinkedHashMap"),
"must use LinkedHashMap for fold lookup; got:\n{query_fn}"
);
assert!(
query_fn.contains("lookup.containsKey(key)"),
"must fold with containsKey; got:\n{query_fn}"
);
assert!(
query_fn.contains("children.add(child)"),
"must append child; got:\n{query_fn}"
);
}
fn make_one_query_with_snake_case_columns() -> AnalyzedQuery {
AnalyzedQuery::build(|q| {
q.name = "GetSession".to_string();
q.command = QueryCommand::One;
q.sql = "SELECT id, user_id FROM sessions WHERE id = $1".to_string();
q.columns = vec![
AnalyzedColumn {
name: "id".to_string(),
neutral_type: "int32".to_string(),
nullable: false,
..Default::default()
},
AnalyzedColumn {
name: "user_id".to_string(),
neutral_type: "int32".to_string(),
nullable: false,
..Default::default()
},
];
q.params = vec![AnalyzedParam {
name: "id".to_string(),
neutral_type: "int32".to_string(),
nullable: false,
position: 1,
source_relation: None,
}];
q.deprecated = None;
q.source_table = None;
q.composites = vec![];
q.enums = vec![];
q.optional_params = vec![];
q.group_by = None;
q.custom = vec![];
})
}
#[test]
fn test_field_case_camel_case_renames_field_but_keeps_raw_lookup_key() {
let mut backend = KotlinExposedBackend::new("postgresql").unwrap();
backend
.apply_options(&HashMap::from([("field_case".to_string(), "camelCase".to_string())]))
.unwrap();
let query = make_one_query_with_snake_case_columns();
let result = crate::generate_with_backend(&query, &backend).unwrap();
let row_struct = result.row_struct.as_deref().unwrap();
let query_fn = result.query_fn.as_deref().unwrap();
assert!(
row_struct.contains("val userId: Int"),
"field_case must rename the declared data class property; got:\n{row_struct}"
);
assert!(
!row_struct.contains("val user_id"),
"must not leave the raw SQL name in the declared property; got:\n{row_struct}"
);
assert!(
query_fn.contains("userId = rs.getInt(\"user_id\")"),
"the ResultSet lookup key must stay the raw SQL column name; got:\n{query_fn}"
);
assert!(
!query_fn.contains("rs.getInt(\"userId\")"),
"must never look the driver up by the renamed field; got:\n{query_fn}"
);
}
fn make_select_star_query() -> AnalyzedQuery {
AnalyzedQuery::build(|q| {
q.name = "GetAllUsers".to_string();
q.command = QueryCommand::Many;
q.sql = "SELECT * FROM users".to_string();
q.columns = vec![
AnalyzedColumn {
name: "id".to_string(),
neutral_type: "int32".to_string(),
nullable: false,
..Default::default()
},
AnalyzedColumn {
name: "name".to_string(),
neutral_type: "string".to_string(),
nullable: false,
..Default::default()
},
];
q.params = vec![];
q.deprecated = None;
q.source_table = Some("users".to_string());
q.composites = vec![];
q.enums = vec![];
q.optional_params = vec![];
q.group_by = None;
q.custom = vec![];
})
}
#[test]
fn test_select_star_declares_both_the_table_object_and_the_row_data_class() {
let backend = KotlinExposedBackend::new("postgresql").unwrap();
let query = make_select_star_query();
let result = crate::generate_with_backend(&query, &backend).unwrap();
let model_struct = result.model_struct.as_deref().unwrap();
let query_fn = result.query_fn.as_deref().unwrap();
assert!(
model_struct.contains("object UserTable : IntIdTable(\"users\") {"),
"must still declare the Exposed table object; got:\n{model_struct}"
);
assert!(
model_struct.contains("data class User("),
"must also declare the row type the query function references; got:\n{model_struct}"
);
assert!(
query_fn.contains("User("),
"the query function must construct the declared row type; got:\n{query_fn}"
);
}
#[test]
fn test_field_case_option_rejects_invalid_value() {
let mut backend = KotlinExposedBackend::new("postgresql").unwrap();
let result = backend.apply_options(&HashMap::from([("field_case".to_string(), "PascalCase".to_string())]));
assert!(result.is_err(), "expected 'PascalCase' to be rejected");
}
fn make_composite_with_consecutive_capitals() -> CompositeInfo {
CompositeInfo {
sql_name: "CreateAPIKey".to_string(),
fields: vec![
CompositeFieldInfo {
name: "HTTPSUrl".to_string(),
neutral_type: "string".to_string(),
nullable: false,
},
CompositeFieldInfo {
name: "internal_id".to_string(),
neutral_type: "int32".to_string(),
nullable: false,
},
],
}
}
#[test]
fn test_composite_def_normalizes_consecutive_capitals() {
let backend = KotlinExposedBackend::new("postgresql").unwrap();
let composite = make_composite_with_consecutive_capitals();
let def = backend.generate_composite_def(&composite).unwrap();
assert!(
def.contains("data class CreateApiKey("),
"composite type name must normalize consecutive capitals; got:\n{def}"
);
assert!(
def.contains("val httpsUrl: String"),
"composite field name must normalize consecutive capitals; got:\n{def}"
);
assert!(
def.contains("val internalId: Int"),
"composite field name must still camelCase a plain snake_case field; got:\n{def}"
);
}
}