Current section

Files

Jump to
sqlode src sqlode internal codegen models.gleam
Raw

src/sqlode/internal/codegen/models.gleam

import gleam/dict.{type Dict}
import gleam/list
import gleam/option
import gleam/string
import sqlode/internal/codegen/builder
import sqlode/internal/codegen/common
import sqlode/internal/model
import sqlode/internal/naming
import sqlode/internal/type_mapping
pub fn render(
naming_ctx: naming.NamingContext,
catalog: model.Catalog,
queries: List(model.AnalyzedQuery),
table_matches: Dict(String, String),
type_mapping: model.TypeMapping,
emit_exact_table_names: Bool,
) -> String {
let semantic_aliases = case type_mapping {
model.RichMapping -> render_semantic_type_aliases(catalog, queries)
model.StrongMapping -> render_strong_type_wrappers(catalog, queries)
model.StringMapping -> ""
}
let table_types =
catalog.tables
|> list.map(render_table_type(
naming_ctx,
_,
type_mapping,
emit_exact_table_names,
))
|> string.join("\n\n")
let row_types =
queries
|> list.filter(fn(query) {
model.is_result_command(query.base.command)
&& !list.is_empty(query.result_columns)
})
|> list.map(render_row_type_or_alias(
naming_ctx,
_,
table_matches,
type_mapping,
emit_exact_table_names,
))
|> string.join("\n\n")
let option_imports = case
needs_option_import_for_results(queries)
|| needs_option_import_for_tables(catalog)
{
True -> ["import gleam/option.{type Option}"]
False -> []
}
let custom_imports =
list.flatten([
common.result_scalar_types(queries),
common.catalog_scalar_types(catalog),
])
|> common.custom_type_imports
let set_imports = case has_mysql_set(catalog) {
True -> [
"import gleam/list as mysql_set_list",
"import gleam/string as mysql_set_string",
]
False -> []
}
let imports = list.flatten([option_imports, set_imports, custom_imports])
let custom_type_warning = case has_custom_types_in_results(queries) {
True -> [
"// NOTE: This file uses custom type overrides. Custom types must be",
"// transparent type aliases (e.g., pub type UserId = Int), not opaque types.",
"// Opaque types will cause compile errors because encoding/decoding uses",
"// the underlying primitive type.",
]
False -> []
}
let enum_types =
catalog.enums
|> list.map(render_enum_or_set_type)
|> string.join("\n\n")
let body_parts =
[enum_types, semantic_aliases, table_types, row_types]
|> list.filter(fn(s) { !string.is_empty(s) })
|> string.join("\n\n")
let lines =
list.flatten([
["// Code generated by sqlode. DO NOT EDIT.", ""],
custom_type_warning,
case custom_type_warning {
[] -> []
_ -> [""]
},
imports,
case imports {
[] -> []
_ -> [""]
},
[body_parts],
])
string.join(lines, "\n")
}
fn render_enum_or_set_type(enum_def: model.EnumDef) -> String {
case enum_def.kind {
model.PostgresEnum | model.MySqlEnum -> render_enum_type(enum_def)
model.MySqlSet -> render_set_type(enum_def)
}
}
fn has_mysql_set(catalog: model.Catalog) -> Bool {
list.any(catalog.enums, fn(e) { e.kind == model.MySqlSet })
}
fn render_enum_type(enum_def: model.EnumDef) -> String {
let type_name = type_mapping.enum_type_name(enum_def.name)
let to_string_fn = type_mapping.enum_to_string_fn(enum_def.name)
let from_string_fn = type_mapping.enum_from_string_fn(enum_def.name)
let default_fn = type_mapping.enum_default_fn(enum_def.name)
let first_variant = case enum_def.values {
[first, ..] -> type_mapping.enum_value_name(first)
[] -> type_name
}
let constructors =
builder.lines(list.map(enum_def.values, type_mapping.enum_value_name))
|> builder.indent(by: 2)
let to_string_cases =
builder.lines(
list.map(enum_def.values, fn(v) {
type_mapping.enum_value_name(v) <> " -> \"" <> v <> "\""
}),
)
|> builder.indent(by: 4)
let from_string_cases =
builder.lines(
list.map(enum_def.values, fn(v) {
"\"" <> v <> "\" -> Ok(" <> type_mapping.enum_value_name(v) <> ")"
}),
)
|> builder.indent(by: 4)
builder.concat([
builder.line("pub type " <> type_name <> " {"),
constructors,
builder.line("}"),
builder.blank(),
builder.line(
"pub fn " <> to_string_fn <> "(value: " <> type_name <> ") -> String {",
),
builder.line(" case value {"),
to_string_cases,
builder.line(" }"),
builder.line("}"),
builder.blank(),
builder.line(
"pub fn "
<> from_string_fn
<> "(value: String) -> Result("
<> type_name
<> ", String) {",
),
builder.line(" case value {"),
from_string_cases,
builder.line(
" _ -> Error(\"Unknown " <> enum_def.name <> " value: \" <> value)",
),
builder.line(" }"),
builder.line("}"),
builder.blank(),
// `<name>_default()` is the first variant in declaration order.
// Used solely as the type-inference placeholder for
// `decode.failure` — this value is NEVER returned to callers.
// When `decode.failure` fires, the decoder produces a decode
// error and the placeholder is discarded by `decode.run`.
builder.line("pub fn " <> default_fn <> "() -> " <> type_name <> " {"),
builder.line(" " <> first_variant),
builder.line("}"),
])
|> builder.render
}
/// Render a MySQL `SET('a','b',...)` definition as a Gleam value type
/// plus comma-list helpers. The column itself is typed as
/// `List(<Name>Value)` in generated row models; the helpers below let
/// raw-mode callers turn that list into the comma-joined string MySQL
/// expects on the wire (and back again).
fn render_set_type(enum_def: model.EnumDef) -> String {
let value_type = type_mapping.set_value_type_name(enum_def.name)
let value_to_string_fn = type_mapping.enum_to_string_fn(enum_def.name)
let value_from_string_fn = type_mapping.enum_from_string_fn(enum_def.name)
let set_to_string_fn = type_mapping.set_to_string_fn(enum_def.name)
let set_from_string_fn = type_mapping.set_from_string_fn(enum_def.name)
let constructors =
builder.lines(list.map(enum_def.values, type_mapping.enum_value_name))
|> builder.indent(by: 2)
let to_string_cases =
builder.lines(
list.map(enum_def.values, fn(v) {
type_mapping.enum_value_name(v) <> " -> \"" <> v <> "\""
}),
)
|> builder.indent(by: 4)
let from_string_cases =
builder.lines(
list.map(enum_def.values, fn(v) {
"\"" <> v <> "\" -> Ok(" <> type_mapping.enum_value_name(v) <> ")"
}),
)
|> builder.indent(by: 4)
builder.concat([
builder.line("pub type " <> value_type <> " {"),
constructors,
builder.line("}"),
builder.blank(),
builder.line(
"pub fn "
<> value_to_string_fn
<> "(value: "
<> value_type
<> ") -> String {",
),
builder.line(" case value {"),
to_string_cases,
builder.line(" }"),
builder.line("}"),
builder.blank(),
builder.line(
"pub fn "
<> value_from_string_fn
<> "(value: String) -> Result("
<> value_type
<> ", String) {",
),
builder.line(" case value {"),
from_string_cases,
builder.line(
" _ -> Error(\"Unknown " <> enum_def.name <> " value: \" <> value)",
),
builder.line(" }"),
builder.line("}"),
builder.blank(),
builder.line(
"pub fn "
<> set_to_string_fn
<> "(values: List("
<> value_type
<> ")) -> String {",
),
builder.line(
" values |> mysql_set_list.map("
<> value_to_string_fn
<> ") |> mysql_set_string.join(\",\")",
),
builder.line("}"),
builder.blank(),
builder.line(
"pub fn "
<> set_from_string_fn
<> "(value: String) -> Result(List("
<> value_type
<> "), String) {",
),
builder.line(" case value {"),
builder.line(" \"\" -> Ok([])"),
builder.line(
" _ -> value |> mysql_set_string.split(\",\") |> mysql_set_list.try_map("
<> value_from_string_fn
<> ")",
),
builder.line(" }"),
builder.line("}"),
])
|> builder.render
}
fn render_semantic_type_aliases(
catalog: model.Catalog,
queries: List(model.AnalyzedQuery),
) -> String {
let all_types = collect_rich_scalar_types(catalog, queries)
all_types
|> list.map(fn(scalar_type) {
let alias_name =
type_mapping.scalar_type_to_gleam_type(scalar_type, model.RichMapping)
builder.concat([
builder.line("pub type " <> alias_name <> " ="),
builder.line(" String"),
])
|> builder.render
})
|> string.join("\n\n")
}
fn render_strong_type_wrappers(
catalog: model.Catalog,
queries: List(model.AnalyzedQuery),
) -> String {
let all_types = collect_rich_scalar_types(catalog, queries)
all_types
|> list.map(fn(scalar_type) {
let type_name =
type_mapping.scalar_type_to_gleam_type(scalar_type, model.StrongMapping)
let unwrap_fn = case type_mapping.strong_type_unwrap_fn(scalar_type) {
option.Some(fn_name) -> fn_name
option.None -> type_name <> "_to_string"
}
let body = "let " <> type_name <> "(inner) = value\n inner"
builder.concat([
builder.line(common.gleam_type(type_name, "String")),
builder.blank(),
builder.line(common.gleam_fn(
unwrap_fn,
"value: " <> type_name,
"String",
body,
)),
])
|> builder.render
})
|> string.join("\n\n")
}
fn collect_rich_scalar_types(
catalog: model.Catalog,
queries: List(model.AnalyzedQuery),
) -> List(model.ScalarType) {
let table_types =
catalog.tables
|> list.flat_map(fn(table) {
list.filter_map(table.columns, fn(col) {
case type_mapping.is_rich_type(col.scalar_type) {
True -> Ok(col.scalar_type)
False -> Error(Nil)
}
})
})
let query_types =
queries
|> list.flat_map(fn(query) {
list.filter_map(query.result_columns, fn(item) {
case item {
model.ScalarResult(model.ResultColumn(scalar_type:, ..)) ->
case type_mapping.is_rich_type(scalar_type) {
True -> Ok(scalar_type)
False -> Error(Nil)
}
model.EmbeddedResult(..) -> Error(Nil)
}
})
})
// Params bring their own rich scalars into scope. Without this,
// a write-only query (`:exec`) whose table is pruned away by
// `omit_unused_models` would reference `SqlUuid` / `SqlTimestamp`
// in `params.gleam` / `queries.gleam` without the wrapper types
// being emitted from `models.gleam` — the core of Issue #446.
let param_types =
queries
|> list.flat_map(fn(query) {
list.filter_map(query.params, fn(p) {
case type_mapping.is_rich_type(p.scalar_type) {
True -> Ok(p.scalar_type)
False -> Error(Nil)
}
})
})
list.flatten([table_types, query_types, param_types])
|> list.unique
|> list.sort(fn(a, b) {
string.compare(
type_mapping.scalar_type_to_gleam_type(a, model.RichMapping),
type_mapping.scalar_type_to_gleam_type(b, model.RichMapping),
)
})
}
fn needs_option_import_for_results(queries: List(model.AnalyzedQuery)) -> Bool {
list.any(queries, fn(query) {
case model.is_result_command(query.base.command) {
True ->
list.any(query.result_columns, fn(item) {
case item {
model.ScalarResult(model.ResultColumn(nullable: True, ..)) -> True
model.ScalarResult(..) -> False
model.EmbeddedResult(model.EmbeddedColumn(columns:, ..)) ->
list.any(columns, fn(c) { c.nullable })
}
})
False -> False
}
})
}
fn needs_option_import_for_tables(catalog: model.Catalog) -> Bool {
list.any(catalog.tables, fn(table) {
list.any(table.columns, fn(col) { col.nullable })
})
}
fn has_custom_types_in_results(queries: List(model.AnalyzedQuery)) -> Bool {
// The transparent-alias warning only fires for custom types WITHOUT
// codec hooks. A `CustomType(..., codec: Some(_))` is the documented
// opt-in escape hatch for opaque domain types (issue #529); it does
// not need the alias warning because the generated code routes the
// value through the user's encode/decode pair.
list.any(queries, fn(query) {
case model.is_result_command(query.base.command) {
True ->
list.any(query.result_columns, fn(item) {
case item {
model.ScalarResult(model.ResultColumn(
scalar_type: model.CustomType(codec: option.None, ..),
..,
)) -> True
model.ScalarResult(..) -> False
model.EmbeddedResult(model.EmbeddedColumn(columns:, ..)) ->
list.any(columns, fn(c) {
case c.scalar_type {
model.CustomType(codec: option.None, ..) -> True
_ -> False
}
})
}
})
False -> False
}
})
}
fn render_table_type(
naming_ctx: naming.NamingContext,
table: model.Table,
type_mapping: model.TypeMapping,
emit_exact_table_names: Bool,
) -> String {
let type_name =
naming.table_type_name(naming_ctx, table.name, emit_exact_table_names)
let fields =
table.columns
|> list.map(fn(col) {
let gleam_type = case col.nullable {
True ->
"Option("
<> type_mapping.scalar_type_to_gleam_type(
col.scalar_type,
type_mapping,
)
<> ")"
False ->
type_mapping.scalar_type_to_gleam_type(col.scalar_type, type_mapping)
}
naming.to_snake_case(naming_ctx, col.name) <> ": " <> gleam_type
})
|> string.join(", ")
builder.concat([
builder.line("pub type " <> type_name <> " {"),
builder.line(" " <> type_name <> "(" <> fields <> ")"),
builder.line("}"),
])
|> builder.render
}
fn render_row_type_or_alias(
naming_ctx: naming.NamingContext,
query: model.AnalyzedQuery,
table_matches: Dict(String, String),
type_mapping: model.TypeMapping,
emit_exact_table_names: Bool,
) -> String {
let row_type_name =
naming.to_pascal_case(naming_ctx, query.base.name) <> "Row"
case dict.get(table_matches, query.base.function_name) {
Ok(table_type_name) ->
builder.concat([
builder.line("pub type " <> row_type_name <> " ="),
builder.line(" " <> table_type_name),
])
|> builder.render
Error(_) ->
render_row_type(naming_ctx, query, type_mapping, emit_exact_table_names)
}
}
fn render_row_type(
naming_ctx: naming.NamingContext,
query: model.AnalyzedQuery,
type_mapping: model.TypeMapping,
emit_exact_table_names: Bool,
) -> String {
let type_name = naming.to_pascal_case(naming_ctx, query.base.name) <> "Row"
case query.result_columns {
[] -> ""
columns -> {
let fields =
columns
|> list.map(fn(col) {
case col {
model.ScalarResult(model.ResultColumn(
name:,
scalar_type:,
nullable:,
..,
)) -> {
let gleam_type = case nullable {
True ->
"Option("
<> type_mapping.scalar_type_to_gleam_type(
scalar_type,
type_mapping,
)
<> ")"
False ->
type_mapping.scalar_type_to_gleam_type(
scalar_type,
type_mapping,
)
}
naming.to_snake_case(naming_ctx, name) <> ": " <> gleam_type
}
model.EmbeddedResult(model.EmbeddedColumn(name:, table_name:, ..)) ->
naming.to_snake_case(naming_ctx, name)
<> ": "
<> naming.table_type_name(
naming_ctx,
table_name,
emit_exact_table_names,
)
}
})
|> string.join(", ")
builder.concat([
builder.line("pub type " <> type_name <> " {"),
builder.line(" " <> type_name <> "(" <> fields <> ")"),
builder.line("}"),
])
|> builder.render
}
}
}