Current section
Files
Jump to
Current section
Files
src/sqlode/codegen/queries.gleam
import gleam/dict.{type Dict}
import gleam/int
import gleam/list
import gleam/string
import sqlode/codegen/common
import sqlode/model
import sqlode/naming
import sqlode/runtime
import sqlode/type_mapping
pub fn render(
naming_ctx: naming.NamingContext,
block: model.SqlBlock,
queries: List(model.AnalyzedQuery),
table_matches: Dict(String, String),
emit_exact_table_names: Bool,
) -> String {
let model.SqlBlock(engine:, gleam:, ..) = block
let model.GleamOutput(runtime:, out:, ..) = gleam
let module_path = common.out_to_module_path(out)
let emit_sql_as_comment = gleam.emit_sql_as_comment
let has_slices = common.queries_have_slices(queries)
let style_expr = engine_placeholder_style_expr(engine)
let query_functions =
queries
|> list.map(render_query_function(
naming_ctx,
_,
emit_sql_as_comment,
style_expr,
))
|> string.join("\n\n")
// Function-first convenience wrappers. For each generated query
// descriptor, emit a `prepare_<function_name>(...)` helper that
// constructs the params record inline and returns the pair that
// Gleam database drivers consume directly — collapsing the
// three-step "descriptor + params constructor + runtime.prepare"
// composition into a single call at the use site (Issue #394).
let prepare_functions =
queries
|> list.map(render_prepare_function(naming_ctx, _, gleam.type_mapping))
|> list.filter(fn(s) { s != "" })
|> string.join("\n\n")
let all_items = case queries {
[] -> "[]"
_ ->
queries
|> list.map(fn(query) { render_query_info_literal(query.base, engine) })
|> string.join(", ")
|> fn(items) { "[" <> items <> "]" }
}
let has_any_params = list.any(queries, fn(q) { !list.is_empty(q.params) })
let decoder_queries =
queries
|> list.filter(fn(q) { has_result_columns(q) })
let decoder_functions = case decoder_queries {
[] -> ""
_ ->
decoder_queries
|> list.map(render_decoder_function(
naming_ctx,
_,
engine,
module_path,
table_matches,
emit_exact_table_names,
gleam.type_mapping,
))
|> string.join("\n\n")
}
let has_decoders = !list.is_empty(decoder_queries)
let has_nullable =
{
has_decoders
&& list.any(decoder_queries, fn(q) {
list.any(q.result_columns, fn(col) {
case col {
model.ScalarResult(model.ResultColumn(nullable: True, ..)) -> True
_ -> False
}
})
})
}
|| list.any(queries, fn(q) { list.any(q.params, fn(p) { p.nullable }) })
// Param rich scalars in `prepare_*` signatures flow through
// `common.qualified_field_type`, which prefixes them with
// `models.`. `queries.gleam` therefore needs the models import
// whenever any prepare-helper references a rich scalar, not only
// when the file has row-returning decoders.
let needs_models_for_rich_params =
{
gleam.type_mapping == model.StrongMapping
|| gleam.type_mapping == model.RichMapping
}
&& list.any(queries, fn(q) {
list.any(q.params, fn(p) { type_mapping.is_rich_type(p.scalar_type) })
})
// Module-qualified custom types referenced by `prepare_*` need
// their own selective `import myapp/types.{type UserId}` line so
// the generated signatures compile as written.
let custom_imports =
common.param_scalar_types(queries)
|> common.custom_type_imports
let imports =
list.flatten([
case has_slices {
True -> ["import gleam/list"]
False -> []
},
case has_decoders {
True -> ["import gleam/dynamic/decode"]
False -> []
},
case has_nullable {
True -> ["import gleam/option.{type Option}"]
False -> []
},
["import " <> common.runtime_import_path(gleam)],
case has_any_params {
True -> ["import " <> module_path <> "/params"]
False -> []
},
case has_decoders || needs_models_for_rich_params {
True -> ["import " <> module_path <> "/models"]
False -> []
},
custom_imports,
])
string.join(
list.flatten([
[
"// Code generated by sqlode. DO NOT EDIT.",
"// engine: " <> model.engine_to_string(engine),
"// runtime: " <> model.runtime_to_string(runtime),
"",
],
imports,
[
"",
"pub fn all() -> List(runtime.QueryInfo) {",
" " <> all_items,
"}",
"",
query_functions,
],
case prepare_functions {
"" -> []
_ -> ["", prepare_functions]
},
case decoder_functions {
"" -> []
_ -> ["", decoder_functions]
},
]),
"\n",
)
}
fn engine_placeholder_style_expr(engine: model.Engine) -> String {
case engine {
model.PostgreSQL -> "runtime.DollarNumbered"
model.MySQL -> "runtime.QuestionPositional"
model.SQLite -> "runtime.QuestionNumbered"
}
}
fn render_query_function(
naming_ctx: naming.NamingContext,
query: model.AnalyzedQuery,
emit_sql_as_comment: Bool,
style_expr: String,
) -> String {
let has_params = !list.is_empty(query.params)
let comment = case emit_sql_as_comment {
True -> ["// SQL: " <> query.base.sql]
False -> []
}
let return_type = case has_params {
True -> {
let params_type =
naming.to_pascal_case(naming_ctx, query.base.name) <> "Params"
"runtime.RawQuery(params." <> params_type <> ")"
}
False -> "runtime.RawQuery(Nil)"
}
let encode_line = case has_params {
True -> {
let values_fn = query.base.function_name <> "_values"
" encode: params." <> values_fn <> ","
}
False -> " encode: fn(_) { [] },"
}
let slice_params = list.filter(query.params, fn(p) { p.is_list })
let slice_info_line = case slice_params {
[] -> " slice_info: fn(_) { [] },"
_ -> {
let entries =
slice_params
|> list.map(fn(p) {
"#("
<> int.to_string(p.index)
<> ", list.length(p."
<> p.field_name
<> "))"
})
|> string.join(", ")
" slice_info: fn(p) { [" <> entries <> "] },"
}
}
string.join(
list.flatten([
comment,
[
"pub fn " <> query.base.function_name <> "() -> " <> return_type <> " {",
" runtime.RawQuery(",
" name: \"" <> common.escape_string(query.base.name) <> "\",",
" sql: \"" <> common.escape_string(query.base.sql) <> "\",",
" command: runtime."
<> model.query_command_to_string(query.base.command)
<> ",",
" param_count: " <> int.to_string(query.base.param_count) <> ",",
" placeholder_style: " <> style_expr <> ",",
encode_line,
slice_info_line,
" )",
"}",
],
]),
"\n",
)
}
/// Emit a `prepare_<function_name>` convenience wrapper that
/// constructs the generated params record inline (for parameterised
/// queries) or passes `Nil` (for parameterless queries) and then
/// returns `runtime.prepare(…)` directly. Callers consume the
/// `#(sql, values)` tuple without composing the descriptor, params
/// record and `runtime.prepare` call by hand.
fn render_prepare_function(
naming_ctx: naming.NamingContext,
query: model.AnalyzedQuery,
type_mapping: model.TypeMapping,
) -> String {
let type_name = naming.to_pascal_case(naming_ctx, query.base.name) <> "Params"
let descriptor_call = query.base.function_name <> "()"
case query.params {
[] ->
string.join(
[
"pub fn prepare_"
<> query.base.function_name
<> "() -> #(String, List(runtime.Value)) {",
" runtime.prepare(" <> descriptor_call <> ", Nil)",
"}",
],
"\n",
)
params -> {
let arg_list =
params
|> list.map(fn(p) {
p.field_name <> ": " <> param_arg_type(p, type_mapping)
})
|> string.join(", ")
let constructor_args =
params
|> list.map(fn(p) { p.field_name <> ": " <> p.field_name })
|> string.join(", ")
string.join(
[
"pub fn prepare_"
<> query.base.function_name
<> "("
<> arg_list
<> ") -> #(String, List(runtime.Value)) {",
" runtime.prepare(",
" " <> descriptor_call <> ",",
" params." <> type_name <> "(" <> constructor_args <> "),",
" )",
"}",
],
"\n",
)
}
}
}
/// Render the Gleam type of a single parameter for a `prepare_*`
/// argument position. Mirrors the type used in the `Params` record
/// field: base type, optionally wrapped in `Option(…)` when the
/// parameter is nullable or `List(…)` when it is a slice.
fn param_arg_type(
param: model.QueryParam,
type_mapping: model.TypeMapping,
) -> String {
let base = common.qualified_field_type(param.scalar_type, type_mapping)
case param.is_list {
True -> "List(" <> base <> ")"
False ->
case param.nullable {
True -> "Option(" <> base <> ")"
False -> base
}
}
}
fn has_result_columns(query: model.AnalyzedQuery) -> Bool {
case query.base.command {
runtime.QueryOne | runtime.QueryMany -> !list.is_empty(query.result_columns)
_ -> False
}
}
fn render_decoder_function(
naming_ctx: naming.NamingContext,
query: model.AnalyzedQuery,
engine: model.Engine,
_module_path: String,
table_matches: Dict(String, String),
emit_exact_table_names: Bool,
type_mapping_mode: model.TypeMapping,
) -> String {
let type_name = naming.to_pascal_case(naming_ctx, query.base.name) <> "Row"
let constructor_name = case
dict.get(table_matches, query.base.function_name)
{
Ok(table_type) -> table_type
Error(_) -> type_name
}
let #(_, field_lines) =
list.fold(query.result_columns, #(0, []), fn(acc, col) {
let #(idx, lines) = acc
case col {
model.ScalarResult(model.ResultColumn(
name:,
scalar_type:,
nullable:,
..,
)) -> {
let field_name = naming.to_snake_case(naming_ctx, name)
let base =
render_raw_base_decoder(engine, scalar_type, type_mapping_mode)
let decoder = case nullable {
True -> "decode.optional(" <> base <> ")"
False -> base
}
let line =
" use "
<> field_name
<> " <- decode.field("
<> int.to_string(idx)
<> ", "
<> decoder
<> ")"
#(idx + 1, list.append(lines, [line]))
}
model.EmbeddedResult(model.EmbeddedColumn(
name: embed_name,
columns: embed_cols,
table_name: embed_table_name,
)) -> {
let embed_field_name = naming.to_snake_case(naming_ctx, embed_name)
let embed_type_name =
naming.table_type_name(
naming_ctx,
embed_table_name,
emit_exact_table_names,
)
let #(new_idx, decode_lines) =
list.fold(embed_cols, #(idx, []), fn(inner_acc, c) {
let #(i, ls) = inner_acc
let field_name = naming.to_snake_case(naming_ctx, c.name)
let base =
render_raw_base_decoder(
engine,
c.scalar_type,
type_mapping_mode,
)
let decoder = case c.nullable {
True -> "decode.optional(" <> base <> ")"
False -> base
}
let line =
" use "
<> field_name
<> " <- decode.field("
<> int.to_string(i)
<> ", "
<> decoder
<> ")"
#(i + 1, list.append(ls, [line]))
})
let embed_constructor_fields =
list.map(embed_cols, fn(c) {
naming.to_snake_case(naming_ctx, c.name) <> ":"
})
|> string.join(", ")
let constructor_line =
" let "
<> embed_field_name
<> " = models."
<> embed_type_name
<> "("
<> embed_constructor_fields
<> ")"
let all_lines = list.append(decode_lines, [constructor_line])
#(new_idx, list.append(lines, all_lines))
}
}
})
let constructor_fields =
query.result_columns
|> list.flat_map(fn(col) {
case col {
model.ScalarResult(model.ResultColumn(name:, ..)) -> [
naming.to_snake_case(naming_ctx, name) <> ":",
]
model.EmbeddedResult(model.EmbeddedColumn(name:, ..)) -> [
naming.to_snake_case(naming_ctx, name) <> ":",
]
}
})
|> string.join(", ")
string.join(
list.flatten([
[
"pub fn "
<> query.base.function_name
<> "_decoder() -> decode.Decoder(models."
<> type_name
<> ") {",
],
field_lines,
[
" decode.success(models."
<> constructor_name
<> "("
<> constructor_fields
<> "))",
"}",
],
]),
"\n",
)
}
/// Raw-runtime counterpart of `adapter.gleam`'s `render_base_decoder`.
/// Under strong type mapping, rich scalar types (TIMESTAMP, DATE,
/// UUID, etc.) need the decoded primitive wrapped by their generated
/// `models.<Wrapper>` constructor so the resulting decoder produces
/// a value the row type can consume. Without this, raw `queries.gleam`
/// would decode a bare `String` / `Int` and call the row constructor
/// with a type mismatch — a regression Issue #445 tracks.
fn render_raw_base_decoder(
engine: model.Engine,
scalar_type: model.ScalarType,
type_mapping_mode: model.TypeMapping,
) -> String {
case scalar_type {
model.EnumType(_) | model.SetType(_) ->
common.render_enum_or_set_decoder(
scalar_type,
type_mapping.scalar_type_to_decoder(engine, _),
)
_ ->
case
type_mapping_mode == model.StrongMapping
&& type_mapping.is_rich_type(scalar_type)
{
True -> {
let constructor =
type_mapping.scalar_type_to_gleam_type(
scalar_type,
model.StrongMapping,
)
"decode.map("
<> type_mapping.scalar_type_to_decoder(engine, scalar_type)
<> ", models."
<> constructor
<> ")"
}
False -> type_mapping.scalar_type_to_decoder(engine, scalar_type)
}
}
}
fn render_query_info_literal(
query: model.ParsedQuery,
engine: model.Engine,
) -> String {
let sql = resolve_static_placeholders(query.sql, query.param_count, engine)
"runtime.QueryInfo(name: \""
<> common.escape_string(query.name)
<> "\", sql: \""
<> common.escape_string(sql)
<> "\", command: runtime."
<> model.query_command_to_string(query.command)
<> ", param_count: "
<> int.to_string(query.param_count)
<> ")"
}
/// Replace internal `__sqlode_param_N__` and `__sqlode_slice_N__` markers with
/// engine-native placeholders for static display in QueryInfo.sql.
/// Slice markers are treated as a single placeholder since their runtime
/// expansion size is unknown at generation time.
fn resolve_static_placeholders(
sql: String,
param_count: Int,
engine: model.Engine,
) -> String {
int.range(from: 1, to: param_count + 1, with: sql, run: fn(current_sql, idx) {
let param = runtime.param_marker(idx)
let slice = runtime.slice_marker(idx)
let placeholder = case engine {
model.PostgreSQL -> "$" <> int.to_string(idx)
model.SQLite -> "?" <> int.to_string(idx)
model.MySQL -> "?"
}
current_sql
|> string.replace(param, placeholder)
|> string.replace(slice, placeholder)
})
}