🐿️ Type safe SQL in Gleam
81

Configure Feed

Select the types of activity you want to include in your feed.

try inferring parameter names

Giacomo Cavalieri (Jun 2, 2026, 8:51 PM +0200) a6b524fd 67663731

+1162 -26
+3 -3
birdie_snapshots/array_encoding.accepted
··· 1 1 --- 2 - version: 1.4.0 2 + version: 1.5.5 3 3 title: array encoding 4 4 file: ./test/squirrel_test.gleam 5 5 test_name: array_encoding_test ··· 14 14 15 15 pub fn query( 16 16 db: pog.Connection, 17 - arg_1: List(Int), 17 + array: List(Int), 18 18 ) -> Result(pog.Returned(QueryRow), pog.QueryError) { 19 19 let decoder = { 20 20 use res <- decode.field(0, decode.bool) ··· 23 23 24 24 "select true as res where $1 = array[1, 2, 3]" 25 25 |> pog.query 26 - |> pog.parameter(pog.array(fn(value) { pog.int(value) }, arg_1)) 26 + |> pog.parameter(pog.array(fn(value) { pog.int(value) }, array)) 27 27 |> pog.returning(decoder) 28 28 |> pog.execute(db) 29 29 }
+38
birdie_snapshots/can_infer_multiple_arguments.accepted
··· 1 + --- 2 + version: 1.5.5 3 + title: can infer multiple arguments 4 + file: ./test/squirrel_test.gleam 5 + test_name: can_infer_multiple_arguments_test 6 + --- 7 + 8 + import gleam/dynamic/decode 9 + import pog 10 + 11 + pub type QueryRow { 12 + QueryRow(name: String) 13 + } 14 + 15 + pub fn query( 16 + db: pog.Connection, 17 + squirrel_user_id: Int, 18 + squirrel_user_name: String, 19 + ) -> Result(pog.Returned(QueryRow), pog.QueryError) { 20 + let decoder = { 21 + use name <- decode.field(0, decode.string) 22 + decode.success(QueryRow(name:)) 23 + } 24 + 25 + " 26 + with squirrel_user as (select 1 as squirrel_user_id, 'Louis' as name) 27 + select name 28 + from squirrel_user 29 + where $1 = squirrel_user_id 30 + and squirrel_user.name = $2 31 + " 32 + |> pog.query 33 + |> pog.parameter(pog.int(squirrel_user_id)) 34 + |> pog.parameter(pog.text(squirrel_user_name)) 35 + |> pog.returning(decoder) 36 + |> pog.execute(db) 37 + } 38 +
+33
birdie_snapshots/can_infer_quoted_table_access_on_left_of_equality.accepted
··· 1 + --- 2 + version: 1.5.5 3 + title: can infer quoted table access on left of equality 4 + file: ./test/squirrel_test.gleam 5 + test_name: can_infer_quoted_table_access_on_left_of_equality_test 6 + --- 7 + 8 + import gleam/dynamic/decode 9 + import pog 10 + 11 + pub type QueryRow { 12 + QueryRow(name: String) 13 + } 14 + 15 + pub fn query( 16 + db: pog.Connection, 17 + squirrel_user_special_id: Int, 18 + ) -> Result(pog.Returned(QueryRow), pog.QueryError) { 19 + let decoder = { 20 + use name <- decode.field(0, decode.string) 21 + decode.success(QueryRow(name:)) 22 + } 23 + 24 + " 25 + with squirrel_user as (select 1 as \"special id\", 'Louis' as name) 26 + select name from squirrel_user where squirrel_user.\"special id\" = $1 27 + " 28 + |> pog.query 29 + |> pog.parameter(pog.int(squirrel_user_special_id)) 30 + |> pog.returning(decoder) 31 + |> pog.execute(db) 32 + } 33 +
+33
birdie_snapshots/can_infer_quoted_table_access_on_right_of_equality.accepted
··· 1 + --- 2 + version: 1.5.5 3 + title: can infer quoted table access on right of equality 4 + file: ./test/squirrel_test.gleam 5 + test_name: can_infer_quoted_table_access_on_right_of_equality_test 6 + --- 7 + 8 + import gleam/dynamic/decode 9 + import pog 10 + 11 + pub type QueryRow { 12 + QueryRow(name: String) 13 + } 14 + 15 + pub fn query( 16 + db: pog.Connection, 17 + squirrel_user_special_id: Int, 18 + ) -> Result(pog.Returned(QueryRow), pog.QueryError) { 19 + let decoder = { 20 + use name <- decode.field(0, decode.string) 21 + decode.success(QueryRow(name:)) 22 + } 23 + 24 + " 25 + with squirrel_user as (select 1 as \"special id\", 'Louis' as name) 26 + select name from squirrel_user where $1 = squirrel_user.\"special id\" 27 + " 28 + |> pog.query 29 + |> pog.parameter(pog.int(squirrel_user_special_id)) 30 + |> pog.returning(decoder) 31 + |> pog.execute(db) 32 + } 33 +
+33
birdie_snapshots/can_infer_simple_name_on_left_of_equality.accepted
··· 1 + --- 2 + version: 1.5.5 3 + title: can infer simple name on left of equality 4 + file: ./test/squirrel_test.gleam 5 + test_name: can_infer_simple_name_on_left_of_equality_test 6 + --- 7 + 8 + import gleam/dynamic/decode 9 + import pog 10 + 11 + pub type QueryRow { 12 + QueryRow(name: String) 13 + } 14 + 15 + pub fn query( 16 + db: pog.Connection, 17 + id: Int, 18 + ) -> Result(pog.Returned(QueryRow), pog.QueryError) { 19 + let decoder = { 20 + use name <- decode.field(0, decode.string) 21 + decode.success(QueryRow(name:)) 22 + } 23 + 24 + " 25 + with squirrel_user as (select 1 as id, 'Louis' as name) 26 + select name from squirrel_user where id = $1 27 + " 28 + |> pog.query 29 + |> pog.parameter(pog.int(id)) 30 + |> pog.returning(decoder) 31 + |> pog.execute(db) 32 + } 33 +
+33
birdie_snapshots/can_infer_simple_name_on_right_of_equality.accepted
··· 1 + --- 2 + version: 1.5.5 3 + title: can infer simple name on right of equality 4 + file: ./test/squirrel_test.gleam 5 + test_name: can_infer_simple_name_on_right_of_equality_test 6 + --- 7 + 8 + import gleam/dynamic/decode 9 + import pog 10 + 11 + pub type QueryRow { 12 + QueryRow(name: String) 13 + } 14 + 15 + pub fn query( 16 + db: pog.Connection, 17 + id: Int, 18 + ) -> Result(pog.Returned(QueryRow), pog.QueryError) { 19 + let decoder = { 20 + use name <- decode.field(0, decode.string) 21 + decode.success(QueryRow(name:)) 22 + } 23 + 24 + " 25 + with squirrel_user as (select 1 as id, 'Louis' as name) 26 + select name from squirrel_user where $1 = id 27 + " 28 + |> pog.query 29 + |> pog.parameter(pog.int(id)) 30 + |> pog.returning(decoder) 31 + |> pog.execute(db) 32 + } 33 +
+33
birdie_snapshots/can_infer_simple_quoted_name_on_left_of_equality.accepted
··· 1 + --- 2 + version: 1.5.5 3 + title: can infer simple quoted name on left of equality 4 + file: ./test/squirrel_test.gleam 5 + test_name: can_infer_simple_quoted_name_on_left_of_equality_test 6 + --- 7 + 8 + import gleam/dynamic/decode 9 + import pog 10 + 11 + pub type QueryRow { 12 + QueryRow(name: String) 13 + } 14 + 15 + pub fn query( 16 + db: pog.Connection, 17 + squirrel_id: Int, 18 + ) -> Result(pog.Returned(QueryRow), pog.QueryError) { 19 + let decoder = { 20 + use name <- decode.field(0, decode.string) 21 + decode.success(QueryRow(name:)) 22 + } 23 + 24 + " 25 + with squirrel_user as (select 1 as \"squirrel id\", 'Louis' as name) 26 + select name from squirrel_user where \"squirrel id\" = $1 27 + " 28 + |> pog.query 29 + |> pog.parameter(pog.int(squirrel_id)) 30 + |> pog.returning(decoder) 31 + |> pog.execute(db) 32 + } 33 +
+33
birdie_snapshots/can_infer_simple_quoted_name_on_right_of_equality.accepted
··· 1 + --- 2 + version: 1.5.5 3 + title: can infer simple quoted name on right of equality 4 + file: ./test/squirrel_test.gleam 5 + test_name: can_infer_simple_quoted_name_on_right_of_equality_test 6 + --- 7 + 8 + import gleam/dynamic/decode 9 + import pog 10 + 11 + pub type QueryRow { 12 + QueryRow(name: String) 13 + } 14 + 15 + pub fn query( 16 + db: pog.Connection, 17 + squirrel_id: Int, 18 + ) -> Result(pog.Returned(QueryRow), pog.QueryError) { 19 + let decoder = { 20 + use name <- decode.field(0, decode.string) 21 + decode.success(QueryRow(name:)) 22 + } 23 + 24 + " 25 + with squirrel_user as (select 1 as \"squirrel id\", 'Louis' as name) 26 + select name from squirrel_user where $1 = \"squirrel id\" 27 + " 28 + |> pog.query 29 + |> pog.parameter(pog.int(squirrel_id)) 30 + |> pog.returning(decoder) 31 + |> pog.execute(db) 32 + } 33 +
+33
birdie_snapshots/can_infer_table_access_on_left_of_equality.accepted
··· 1 + --- 2 + version: 1.5.5 3 + title: can infer table access on left of equality 4 + file: ./test/squirrel_test.gleam 5 + test_name: can_infer_table_access_on_left_of_equality_test 6 + --- 7 + 8 + import gleam/dynamic/decode 9 + import pog 10 + 11 + pub type QueryRow { 12 + QueryRow(name: String) 13 + } 14 + 15 + pub fn query( 16 + db: pog.Connection, 17 + squirrel_user_id: Int, 18 + ) -> Result(pog.Returned(QueryRow), pog.QueryError) { 19 + let decoder = { 20 + use name <- decode.field(0, decode.string) 21 + decode.success(QueryRow(name:)) 22 + } 23 + 24 + " 25 + with squirrel_user as (select 1 as id, 'Louis' as name) 26 + select name from squirrel_user where squirrel_user.id = $1 27 + " 28 + |> pog.query 29 + |> pog.parameter(pog.int(squirrel_user_id)) 30 + |> pog.returning(decoder) 31 + |> pog.execute(db) 32 + } 33 +
+33
birdie_snapshots/can_infer_table_access_on_right_of_equality.accepted
··· 1 + --- 2 + version: 1.5.5 3 + title: can infer table access on right of equality 4 + file: ./test/squirrel_test.gleam 5 + test_name: can_infer_table_access_on_right_of_equality_test 6 + --- 7 + 8 + import gleam/dynamic/decode 9 + import pog 10 + 11 + pub type QueryRow { 12 + QueryRow(name: String) 13 + } 14 + 15 + pub fn query( 16 + db: pog.Connection, 17 + squirrel_user_id: Int, 18 + ) -> Result(pog.Returned(QueryRow), pog.QueryError) { 19 + let decoder = { 20 + use name <- decode.field(0, decode.string) 21 + decode.success(QueryRow(name:)) 22 + } 23 + 24 + " 25 + with squirrel_user as (select 1 as id, 'Louis' as name) 26 + select name from squirrel_user where $1 = squirrel_user.id 27 + " 28 + |> pog.query 29 + |> pog.parameter(pog.int(squirrel_user_id)) 30 + |> pog.returning(decoder) 31 + |> pog.execute(db) 32 + } 33 +
+37
birdie_snapshots/comments_are_ignored_when_inferring_parameters_names.accepted
··· 1 + --- 2 + version: 1.5.5 3 + title: comments are ignored when inferring parameters names 4 + file: ./test/squirrel_test.gleam 5 + test_name: comments_are_ignored_when_inferring_parameters_names_test 6 + --- 7 + 8 + import gleam/dynamic/decode 9 + import pog 10 + 11 + pub type QueryRow { 12 + QueryRow(name: String) 13 + } 14 + 15 + pub fn query( 16 + db: pog.Connection, 17 + name: String, 18 + ) -> Result(pog.Returned(QueryRow), pog.QueryError) { 19 + let decoder = { 20 + use name <- decode.field(0, decode.string) 21 + decode.success(QueryRow(name:)) 22 + } 23 + 24 + " 25 + with squirrel_user as (select 1 as squirrel_user_id, 'Louis' as name) 26 + select name 27 + from squirrel_user 28 + -- $1 = id 29 + where $1 = name 30 + -- $1 = id 31 + " 32 + |> pog.query 33 + |> pog.parameter(pog.text(name)) 34 + |> pog.returning(decoder) 35 + |> pog.execute(db) 36 + } 37 +
+37
birdie_snapshots/multiline_comments_are_ignored_when_inferring_parameters_names.accepted
··· 1 + --- 2 + version: 1.5.5 3 + title: multiline comments are ignored when inferring parameters names 4 + file: ./test/squirrel_test.gleam 5 + test_name: multiline_comments_are_ignored_when_inferring_parameters_names_test 6 + --- 7 + 8 + import gleam/dynamic/decode 9 + import pog 10 + 11 + pub type QueryRow { 12 + QueryRow(name: String) 13 + } 14 + 15 + pub fn query( 16 + db: pog.Connection, 17 + name: String, 18 + ) -> Result(pog.Returned(QueryRow), pog.QueryError) { 19 + let decoder = { 20 + use name <- decode.field(0, decode.string) 21 + decode.success(QueryRow(name:)) 22 + } 23 + 24 + " 25 + with squirrel_user as (select 1 as squirrel_user_id, 'Louis' as name) 26 + select name 27 + from squirrel_user 28 + /* $1 = id */ 29 + where $1 = name 30 + /* $1 = id */ 31 + " 32 + |> pog.query 33 + |> pog.parameter(pog.text(name)) 34 + |> pog.returning(decoder) 35 + |> pog.execute(db) 36 + } 37 +
+37
birdie_snapshots/multiline_nested_comments_are_ignored_when_inferring_parameters_names.accepted
··· 1 + --- 2 + version: 1.5.5 3 + title: multiline nested comments are ignored when inferring parameters names 4 + file: ./test/squirrel_test.gleam 5 + test_name: multiline_nested_comments_are_ignored_when_inferring_parameters_names_test 6 + --- 7 + 8 + import gleam/dynamic/decode 9 + import pog 10 + 11 + pub type QueryRow { 12 + QueryRow(name: String) 13 + } 14 + 15 + pub fn query( 16 + db: pog.Connection, 17 + name: String, 18 + ) -> Result(pog.Returned(QueryRow), pog.QueryError) { 19 + let decoder = { 20 + use name <- decode.field(0, decode.string) 21 + decode.success(QueryRow(name:)) 22 + } 23 + 24 + " 25 + with squirrel_user as (select 1 as squirrel_user_id, 'Louis' as name) 26 + select name 27 + from squirrel_user 28 + /* $1 = id /* $1 = id */ */ 29 + where $1 = name 30 + /* $1 = id /* $1 = id */ */ 31 + " 32 + |> pog.query 33 + |> pog.parameter(pog.text(name)) 34 + |> pog.returning(decoder) 35 + |> pog.execute(db) 36 + } 37 +
+3 -3
birdie_snapshots/recursive_common_table_query_with_semi_join.accepted
··· 1 1 --- 2 - version: 1.4.0 2 + version: 1.5.5 3 3 title: recursive common table query with semi join 4 4 file: ./test/squirrel_test.gleam 5 5 test_name: recursive_common_table_query_with_semi_join_test ··· 15 15 16 16 pub fn query( 17 17 db: pog.Connection, 18 - arg_1: Uuid, 18 + id: Uuid, 19 19 ) -> Result(pog.Returned(QueryRow), pog.QueryError) { 20 20 let decoder = { 21 21 use id <- decode.field(0, uuid_decoder()) ··· 40 40 left join items_categories_issue75 ic on ic.item_id = i.id 41 41 where ic.category_id in (select id from subcategories);" 42 42 |> pog.query 43 - |> pog.parameter(pog.text(uuid.to_string(arg_1))) 43 + |> pog.parameter(pog.text(uuid.to_string(id))) 44 44 |> pog.returning(decoder) 45 45 |> pog.execute(db) 46 46 }
+37
birdie_snapshots/strings_are_ignored_when_inferring_parameters_names.accepted
··· 1 + --- 2 + version: 1.5.5 3 + title: strings are ignored when inferring parameters names 4 + file: ./test/squirrel_test.gleam 5 + test_name: strings_are_ignored_when_inferring_parameters_names_test 6 + --- 7 + 8 + import gleam/dynamic/decode 9 + import pog 10 + 11 + pub type QueryRow { 12 + QueryRow(name: String) 13 + } 14 + 15 + pub fn query( 16 + db: pog.Connection, 17 + name: String, 18 + ) -> Result(pog.Returned(QueryRow), pog.QueryError) { 19 + let decoder = { 20 + use name <- decode.field(0, decode.string) 21 + decode.success(QueryRow(name:)) 22 + } 23 + 24 + " 25 + with squirrel_user as (select 1 as squirrel_user_id, 'Louis' as name) 26 + select name 27 + from squirrel_user 28 + where $1 = '$1 = id' 29 + and $1 = name 30 + and $1 = '$1 = id' 31 + " 32 + |> pog.query 33 + |> pog.parameter(pog.text(name)) 34 + |> pog.returning(decoder) 35 + |> pog.execute(db) 36 + } 37 +
+3 -3
birdie_snapshots/uuid_encoding.accepted
··· 1 1 --- 2 - version: 1.4.0 2 + version: 1.5.5 3 3 title: uuid encoding 4 4 file: ./test/squirrel_test.gleam 5 5 test_name: uuid_encoding_test ··· 15 15 16 16 pub fn query( 17 17 db: pog.Connection, 18 - arg_1: Uuid, 18 + gen_random_uuid: Uuid, 19 19 ) -> Result(pog.Returned(QueryRow), pog.QueryError) { 20 20 let decoder = { 21 21 use res <- decode.field(0, decode.bool) ··· 24 24 25 25 "select true as res where $1 = gen_random_uuid()" 26 26 |> pog.query 27 - |> pog.parameter(pog.text(uuid.to_string(arg_1))) 27 + |> pog.parameter(pog.text(uuid.to_string(gen_random_uuid))) 28 28 |> pog.returning(decoder) 29 29 |> pog.execute(db) 30 30 }
+9 -2
src/squirrel/internal/database/postgres.gleam
··· 1070 1070 } 1071 1071 } 1072 1072 1073 - fn expect_parse_complete(msg: pg.BackendMessage, query: UntypedQuery) -> Db(Nil) { 1073 + fn expect_parse_complete( 1074 + msg: pg.BackendMessage, 1075 + query: UntypedQuery, 1076 + ) -> Db(Nil) { 1074 1077 case msg { 1075 1078 pg.BeParseComplete -> eval.return(Nil) 1076 1079 ··· 1283 1286 ) 1284 1287 } 1285 1288 1286 - fn invalid_enum_error(query: UntypedQuery, enum_name: String, reason: EnumError) { 1289 + fn invalid_enum_error( 1290 + query: UntypedQuery, 1291 + enum_name: String, 1292 + reason: EnumError, 1293 + ) { 1287 1294 let UntypedQuery(content:, file:, name: _, starting_line:, comment: _) = query 1288 1295 error.QueryHasInvalidEnum( 1289 1296 file:,
+536 -15
src/squirrel/internal/query.gleam
··· 4 4 import gleam/dict.{type Dict} 5 5 import gleam/int 6 6 import gleam/list 7 - import gleam/option.{None, Some} 7 + import gleam/option.{type Option, None, Some} 8 8 import gleam/result 9 9 import gleam/set.{type Set} 10 10 import gleam/string 11 + import justin 11 12 import non_empty_list.{type NonEmptyList} 12 13 import simplifile 14 + import splitter.{type Splitter} 13 15 import squirrel/internal/error.{ 14 16 type Error, CannotReadFile, QueryFileHasInvalidName, 15 17 QueryReturnsMultipleValuesWithTheSameName, ··· 49 51 name: gleam.ValueIdentifier, 50 52 comment: List(String), 51 53 content: String, 52 - params: List(gleam.Type), 54 + params: List(Parameter), 53 55 returns: List(gleam.Field), 56 + ) 57 + } 58 + 59 + pub type Parameter { 60 + Parameter( 61 + /// The name inferred for this parameter. 62 + name: Option(gleam.ValueIdentifier), 63 + type_: gleam.Type, 54 64 ) 55 65 } 56 66 ··· 62 72 returns returns: List(gleam.Field), 63 73 ) -> Result(TypedQuery, Error) { 64 74 let UntypedQuery(file:, name:, comment:, content:, starting_line:) = query 65 - 75 + let params = infer_parameter_names(query, params) 66 76 case duplicate_names(returns) { 67 77 [] -> 68 78 Ok(TypedQuery( ··· 394 404 395 405 let CodeGenState(imports:, needs_uuid_decoder:, enums:) = state 396 406 397 - let utils = 398 - [] 399 - |> prepend_if(needs_uuid_decoder, doc.from_string(uuid_decoder)) 407 + let utils = case needs_uuid_decoder { 408 + True -> [doc.from_string(uuid_decoder)] 409 + False -> [] 410 + } 400 411 401 412 // We always want to output the imports and the code for the queries. 402 413 // But in case we also need some helpers we add a final section to our file ··· 515 526 let acc = #(state, [], []) 516 527 use #(state, args, encoders), param, i <- list.index_fold(params, acc) 517 528 518 - let arg = "arg_" <> int.to_string(i + 1) 529 + let arg = generate_argument_name(param, i) 519 530 let #(state, arg_type) = 520 - gleam_type_to_field_type(state, param, FunctionArgument) 521 - let #(state, encoder) = gleam_type_to_encoder(state, param, arg) 531 + gleam_type_to_field_type(state, param.type_, FunctionArgument) 532 + let #(state, encoder) = gleam_type_to_encoder(state, param.type_, arg) 522 533 523 534 let arg = doc.concat([doc.from_string(arg <> ": "), arg_type]) 524 535 #(state, [arg, ..args], [encoder, ..encoders]) ··· 553 564 #(state, code) 554 565 } 555 566 567 + fn generate_argument_name(param: Parameter, position: Int) -> String { 568 + let name = case param.name { 569 + Some(name) -> gleam.value_identifier_to_string(name) 570 + None -> "arg_" <> int.to_string(position + 1) 571 + } 572 + 573 + // There's some names we don't want to use, and if some identifier is a 574 + // keyword. 575 + case name { 576 + "true" | "false" | "null" -> "arg_" <> int.to_string(position + 1) 577 + _ -> name 578 + } 579 + // We need to rename things to avoid conflicting with stuff that exists in 580 + // this scope! 581 + } 582 + 556 583 fn pipe_all_encoders(doc: Document, decoders: List(Document)) -> Document { 557 584 use doc, decoder <- list.fold(over: decoders, from: doc) 558 585 doc |> pipe_call_doc("pog.parameter", _, [decoder]) ··· 1010 1037 1011 1038 /// A comma separated list of items with some given open and closed delimiters. 1012 1039 /// 1013 - fn comma_list(open: String, content: List(Document), close: String) -> Document { 1040 + fn comma_list( 1041 + open: String, 1042 + content: List(Document), 1043 + close: String, 1044 + ) -> Document { 1014 1045 case content { 1015 1046 [] -> doc.from_string(open <> close) 1016 1047 _ -> ··· 1076 1107 }) 1077 1108 } 1078 1109 1079 - // --- MISC UTILS -------------------------------------------------------------- 1110 + // --- SQL QUERY PARAMETERS ANALYSIS ------------------------------------------- 1111 + // This code analyses SQL queries to try and understand the names of the query 1112 + // parameters used in each. 1113 + 1114 + type Context { 1115 + NoIdentifier 1116 + 1117 + // We've found `$1 ...` 1118 + QueryParameter(Int) 1119 + // We've found `$1 = ...` 1120 + QueryParameterEquality(Int) 1121 + 1122 + // We've found `wibble ...` 1123 + Identifier(Name) 1124 + // We've found `wibble = ...` 1125 + IdentifierEquality(Name) 1126 + } 1127 + 1128 + type Name { 1129 + Column(String) 1130 + TableAndColumn(String, String) 1131 + } 1132 + 1133 + type Splitters { 1134 + Splitters(whitespace: Splitter, comments: Splitter, strings: Splitter) 1135 + } 1136 + 1137 + fn infer_parameter_names( 1138 + query: UntypedQuery, 1139 + params: List(gleam.Type), 1140 + ) -> List(Parameter) { 1141 + let names = parameter_names(query.content) 1142 + list.index_map(params, fn(type_, i) { 1143 + case dict.get(names, i + 1) { 1144 + Ok(name) -> Parameter(name: Some(name), type_:) 1145 + Error(_) -> Parameter(name: None, type_:) 1146 + } 1147 + }) 1148 + } 1149 + 1150 + fn parameter_names(query: String) -> Dict(Int, gleam.ValueIdentifier) { 1151 + let query = string.lowercase(query) 1152 + let splitters = 1153 + Splitters( 1154 + whitespace: splitter.new([" ", "\n", "\t", "\r"]), 1155 + comments: splitter.new(["/*", "*/"]), 1156 + strings: splitter.new(["''", "\\'", "'"]), 1157 + ) 1158 + 1159 + parameter_names_loop(splitters, query, NoIdentifier, dict.new()) 1160 + |> dict.fold(dict.new(), fn(acc, parameter, names) { 1161 + case pick_name(names) { 1162 + Ok(name) -> dict.insert(acc, parameter, name) 1163 + Error(_) -> acc 1164 + } 1165 + }) 1166 + } 1167 + 1168 + fn pick_name(names: List(Name)) -> Result(gleam.ValueIdentifier, Nil) { 1169 + list.find_map(list.reverse(names), fn(name) { 1170 + let name = case name { 1171 + Column(name) -> justin.snake_case(name) 1172 + TableAndColumn(table, column) -> { 1173 + let column = string.remove_prefix(table, from: column) 1174 + justin.snake_case(table) <> "_" <> justin.snake_case(column) 1175 + } 1176 + } 1177 + gleam.value_identifier(name) 1178 + }) 1179 + } 1180 + 1181 + fn add_name( 1182 + names: Dict(a, List(b)), 1183 + parameter: a, 1184 + name: b, 1185 + ) -> Dict(a, List(b)) { 1186 + dict.upsert(names, parameter, fn(existing) { 1187 + case existing { 1188 + Some(existing) -> [name, ..existing] 1189 + None -> [name] 1190 + } 1191 + }) 1192 + } 1193 + 1194 + fn parameter_names_loop( 1195 + splitters: Splitters, 1196 + query: String, 1197 + context: Context, 1198 + names: Dict(Int, List(Name)), 1199 + ) -> Dict(Int, List(Name)) { 1200 + case query { 1201 + // COMMENTS 1202 + // We strip those out totally ignoring their content. 1203 + // A comment also won't change the current context. 1204 + "/*" <> query -> multiline_comment_loop(splitters, query, context, 1, names) 1205 + "--" <> query -> 1206 + case string.split_once(query, on: "\n") { 1207 + Error(_) -> names 1208 + Ok(#(_comment, query)) -> 1209 + parameter_names_loop(splitters, query, context, names) 1210 + } 1211 + 1212 + // STRINGS 1213 + // We strip those out ignoring their content. 1214 + "'" <> query -> string_loop(splitters, query, names) 1215 + 1216 + // WHITESPACE 1217 + // Whitespace is ignored and we just move forward. 1218 + // It also doesn't change the current context. 1219 + " " <> query | "\n" <> query | "\r" <> query | "\t" <> query -> 1220 + parameter_names_loop(splitters, query, context, names) 1221 + 1222 + // QUOTED IDENTIFIERS 1223 + // Any sequence of characters in between double quotes. 1224 + "\"" <> query -> 1225 + case string.split_once(query, "\"") { 1226 + // The identifier is not closed, this is a syntax error, we just return 1227 + // all the names we've figured out so far 1228 + Error(_) -> names 1229 + // This could be a dotted identifier!! 1230 + Ok(#(identifier, "." <> query)) -> 1231 + dotted_identifier(splitters, query, context, identifier, names) 1232 + // We've found an identifier, and so update the names and context 1233 + // accordingly. 1234 + Ok(#(identifier, query)) -> 1235 + add_identifier(splitters, query, Column(identifier), context, names) 1236 + } 1237 + 1238 + // IDENTIFIERS/KEYWORDS 1239 + // If we see the start of an identifier we're gonna have to parse the whole 1240 + // word and then keep going to see if it is the name of a query parameter. 1241 + "a" as letter <> query 1242 + | "b" as letter <> query 1243 + | "c" as letter <> query 1244 + | "d" as letter <> query 1245 + | "e" as letter <> query 1246 + | "f" as letter <> query 1247 + | "g" as letter <> query 1248 + | "h" as letter <> query 1249 + | "i" as letter <> query 1250 + | "j" as letter <> query 1251 + | "k" as letter <> query 1252 + | "l" as letter <> query 1253 + | "m" as letter <> query 1254 + | "n" as letter <> query 1255 + | "o" as letter <> query 1256 + | "p" as letter <> query 1257 + | "q" as letter <> query 1258 + | "r" as letter <> query 1259 + | "s" as letter <> query 1260 + | "t" as letter <> query 1261 + | "u" as letter <> query 1262 + | "v" as letter <> query 1263 + | "w" as letter <> query 1264 + | "x" as letter <> query 1265 + | "y" as letter <> query 1266 + | "z" as letter <> query 1267 + | "_" as letter <> query -> 1268 + identifier_loop(splitters, query, letter, context, names) 1269 + 1270 + // QUERY PARAMETERS 1271 + "$1" <> query -> parameter_loop(splitters, query, context, 1, names) 1272 + "$2" <> query -> parameter_loop(splitters, query, context, 2, names) 1273 + "$3" <> query -> parameter_loop(splitters, query, context, 3, names) 1274 + "$4" <> query -> parameter_loop(splitters, query, context, 4, names) 1275 + "$5" <> query -> parameter_loop(splitters, query, context, 5, names) 1276 + "$6" <> query -> parameter_loop(splitters, query, context, 6, names) 1277 + "$7" <> query -> parameter_loop(splitters, query, context, 7, names) 1278 + "$8" <> query -> parameter_loop(splitters, query, context, 8, names) 1279 + "$9" <> query -> parameter_loop(splitters, query, context, 9, names) 1280 + 1281 + // EQUALITY OPERATOR 1282 + // If we see a check for equality we need to update the current state 1283 + // accordingly. 1284 + "=" <> query -> 1285 + case context { 1286 + NoIdentifier | QueryParameterEquality(_) | IdentifierEquality(_) -> 1287 + recover_from_error(splitters, query, names) 1288 + QueryParameter(parameter) -> 1289 + parameter_names_loop( 1290 + splitters, 1291 + query, 1292 + QueryParameterEquality(parameter), 1293 + names, 1294 + ) 1295 + Identifier(identifier) -> 1296 + parameter_names_loop( 1297 + splitters, 1298 + query, 1299 + IdentifierEquality(identifier), 1300 + names, 1301 + ) 1302 + } 1303 + 1304 + // EMPTY STRING 1305 + // That means we're done, there's nothing left to do and we can return all 1306 + // the names we've figured out. 1307 + "" -> names 1308 + 1309 + // UNEXPECTED CHARACTER 1310 + // If we see anything strange that doesn't really look like an identifier 1311 + // or a keyword we will just ignore them and jump forward to the end of the 1312 + // word/operator/number/unexpected character. 1313 + _ -> recover_from_error(splitters, query, names) 1314 + } 1315 + } 1316 + 1317 + fn parameter_loop( 1318 + splitters: Splitters, 1319 + query: String, 1320 + context: Context, 1321 + number: Int, 1322 + names: Dict(Int, List(Name)), 1323 + ) -> Dict(Int, List(Name)) { 1324 + case query { 1325 + "0" <> query -> 1326 + parameter_loop(splitters, query, context, number * 10, names) 1327 + "1" <> query -> 1328 + parameter_loop(splitters, query, context, number * 10 + 1, names) 1329 + "2" <> query -> 1330 + parameter_loop(splitters, query, context, number * 10 + 2, names) 1331 + "3" <> query -> 1332 + parameter_loop(splitters, query, context, number * 10 + 3, names) 1333 + "4" <> query -> 1334 + parameter_loop(splitters, query, context, number * 10 + 4, names) 1335 + "5" <> query -> 1336 + parameter_loop(splitters, query, context, number * 10 + 5, names) 1337 + "6" <> query -> 1338 + parameter_loop(splitters, query, context, number * 10 + 6, names) 1339 + "7" <> query -> 1340 + parameter_loop(splitters, query, context, number * 10 + 7, names) 1341 + "8" <> query -> 1342 + parameter_loop(splitters, query, context, number * 10 + 8, names) 1343 + "9" <> query -> 1344 + parameter_loop(splitters, query, context, number * 10 + 9, names) 1345 + 1346 + // We've found a whitespace, so we know the query parameter is over with, we 1347 + // can now check the context and see if we can record any name for it. 1348 + "" | " " <> _ | "\n" <> _ | "\r" <> _ | "\t" <> _ -> 1349 + add_parameter(splitters, query, number, context, names) 1350 + 1351 + // We've found a strange character meaning that this is not actually a query 1352 + // parameter, but most likely a syntax error. We just bail out and go back 1353 + // to the main loop with a new empty context to avoid this error propagating 1354 + // further and having us infer invalid names. 1355 + _ -> recover_from_error(splitters, query, names) 1356 + } 1357 + } 1358 + 1359 + fn string_loop( 1360 + splitters: Splitters, 1361 + query: String, 1362 + names: Dict(Int, List(Name)), 1363 + ) -> Dict(Int, List(Name)) { 1364 + case splitter.split(splitters.strings, query) { 1365 + // The string is over 1366 + #(_, "'", query) -> 1367 + parameter_names_loop(splitters, query, NoIdentifier, names) 1368 + 1369 + // We've found the escaped closing character, that means we're still inside 1370 + // the string and have to keep going. 1371 + #(_, "''", query) | #(_, "\\'", query) -> 1372 + string_loop(splitters, query, names) 1373 + 1374 + // This is a string that is not closed, we just return the names we've 1375 + // figured out so far 1376 + #(_, _, _) -> names 1377 + } 1378 + } 1379 + 1380 + fn multiline_comment_loop( 1381 + splitters: Splitters, 1382 + query: String, 1383 + context: Context, 1384 + expected_closing_sequences: Int, 1385 + names: Dict(Int, List(Name)), 1386 + ) -> Dict(Int, List(Name)) { 1387 + case expected_closing_sequences { 1388 + 0 -> parameter_names_loop(splitters, query, context, names) 1389 + _ -> 1390 + case splitter.split(splitters.comments, query) { 1391 + #(_, "*/", query) -> 1392 + multiline_comment_loop( 1393 + splitters, 1394 + query, 1395 + context, 1396 + expected_closing_sequences - 1, 1397 + names, 1398 + ) 1399 + #(_, "/*", query) -> 1400 + multiline_comment_loop( 1401 + splitters, 1402 + query, 1403 + context, 1404 + expected_closing_sequences + 1, 1405 + names, 1406 + ) 1407 + // This means the comments are unbalanced, so the query is not right. 1408 + // We just return the names we've found so far! 1409 + _ -> names 1410 + } 1411 + } 1412 + } 1413 + 1414 + fn identifier_loop( 1415 + splitters: Splitters, 1416 + query: String, 1417 + identifier: String, 1418 + context: Context, 1419 + names: Dict(Int, List(Name)), 1420 + ) -> Dict(Int, List(Name)) { 1421 + case query { 1422 + "a" as letter <> query 1423 + | "b" as letter <> query 1424 + | "c" as letter <> query 1425 + | "d" as letter <> query 1426 + | "e" as letter <> query 1427 + | "f" as letter <> query 1428 + | "g" as letter <> query 1429 + | "h" as letter <> query 1430 + | "i" as letter <> query 1431 + | "j" as letter <> query 1432 + | "k" as letter <> query 1433 + | "l" as letter <> query 1434 + | "m" as letter <> query 1435 + | "n" as letter <> query 1436 + | "o" as letter <> query 1437 + | "p" as letter <> query 1438 + | "q" as letter <> query 1439 + | "r" as letter <> query 1440 + | "s" as letter <> query 1441 + | "t" as letter <> query 1442 + | "u" as letter <> query 1443 + | "v" as letter <> query 1444 + | "w" as letter <> query 1445 + | "x" as letter <> query 1446 + | "y" as letter <> query 1447 + | "z" as letter <> query 1448 + | "0" as letter <> query 1449 + | "1" as letter <> query 1450 + | "2" as letter <> query 1451 + | "3" as letter <> query 1452 + | "4" as letter <> query 1453 + | "5" as letter <> query 1454 + | "6" as letter <> query 1455 + | "7" as letter <> query 1456 + | "8" as letter <> query 1457 + | "9" as letter <> query 1458 + | "_" as letter <> query -> 1459 + identifier_loop(splitters, query, identifier <> letter, context, names) 1460 + 1461 + "." <> query -> 1462 + dotted_identifier(splitters, query, context, identifier, names) 1463 + 1464 + // The identifier is over, we can update the context accordingly! 1465 + _ -> add_identifier(splitters, query, Column(identifier), context, names) 1466 + } 1467 + } 1468 + 1469 + fn dotted_identifier( 1470 + splitters: Splitters, 1471 + query: String, 1472 + context: Context, 1473 + table: String, 1474 + names: Dict(Int, List(Name)), 1475 + ) -> Dict(Int, List(Name)) { 1476 + case query { 1477 + // This is a quoted dotted identifier: `user."table"` 1478 + "\"" <> query -> 1479 + case string.split_once(query, "\"") { 1480 + // The identifier is not closed, this is a syntax error, we just return 1481 + // all the names we've figured out so far 1482 + Error(_) -> names 1483 + // We've found an identifier, and so update the names and context 1484 + // accordingly. 1485 + Ok(#(column, query)) -> { 1486 + let name = TableAndColumn(table, column) 1487 + add_identifier(splitters, query, name, context, names) 1488 + } 1489 + } 1490 + 1491 + // Otherwise we treat it as a regular dotted identifier. 1492 + _ -> dotted_identifier_loop(splitters, query, context, table, "", names) 1493 + } 1494 + } 1495 + 1496 + fn dotted_identifier_loop( 1497 + splitters: Splitters, 1498 + query: String, 1499 + context: Context, 1500 + table: String, 1501 + column: String, 1502 + names: Dict(Int, List(Name)), 1503 + ) -> Dict(Int, List(Name)) { 1504 + case query { 1505 + "a" as letter <> query 1506 + | "b" as letter <> query 1507 + | "c" as letter <> query 1508 + | "d" as letter <> query 1509 + | "e" as letter <> query 1510 + | "f" as letter <> query 1511 + | "g" as letter <> query 1512 + | "h" as letter <> query 1513 + | "i" as letter <> query 1514 + | "j" as letter <> query 1515 + | "k" as letter <> query 1516 + | "l" as letter <> query 1517 + | "m" as letter <> query 1518 + | "n" as letter <> query 1519 + | "o" as letter <> query 1520 + | "p" as letter <> query 1521 + | "q" as letter <> query 1522 + | "r" as letter <> query 1523 + | "s" as letter <> query 1524 + | "t" as letter <> query 1525 + | "u" as letter <> query 1526 + | "v" as letter <> query 1527 + | "w" as letter <> query 1528 + | "x" as letter <> query 1529 + | "y" as letter <> query 1530 + | "z" as letter <> query 1531 + | "0" as letter <> query 1532 + | "1" as letter <> query 1533 + | "2" as letter <> query 1534 + | "3" as letter <> query 1535 + | "4" as letter <> query 1536 + | "5" as letter <> query 1537 + | "6" as letter <> query 1538 + | "7" as letter <> query 1539 + | "8" as letter <> query 1540 + | "9" as letter <> query 1541 + | "_" as letter <> query -> { 1542 + let column = column <> letter 1543 + dotted_identifier_loop(splitters, query, context, table, column, names) 1544 + } 1080 1545 1081 - fn prepend_if(list: List(a), condition: Bool, item: a) -> List(a) { 1082 - case condition { 1083 - True -> [item, ..list] 1084 - False -> list 1546 + // The identifier is over, we can update the context accordingly! 1547 + _ -> { 1548 + let name = TableAndColumn(table, column) 1549 + add_identifier(splitters, query, name, context, names) 1550 + } 1085 1551 } 1086 1552 } 1553 + 1554 + fn add_identifier( 1555 + splitters: Splitters, 1556 + query: String, 1557 + identifier: Name, 1558 + context: Context, 1559 + names: Dict(Int, List(Name)), 1560 + ) -> Dict(Int, List(Name)) { 1561 + case context { 1562 + // We're not comparing the identifier agains a query parameter, so we keep 1563 + // track of this new latest identifier we might be interested in! 1564 + IdentifierEquality(_) | Identifier(_) | NoIdentifier | QueryParameter(_) -> 1565 + parameter_names_loop(splitters, query, Identifier(identifier), names) 1566 + 1567 + // We've found `$1 = identifier`, that means we have to record the new 1568 + // name and reset the context! 1569 + QueryParameterEquality(number) -> { 1570 + let names = add_name(names, number, identifier) 1571 + parameter_names_loop(splitters, query, NoIdentifier, names) 1572 + } 1573 + } 1574 + } 1575 + 1576 + fn add_parameter( 1577 + splitters: Splitters, 1578 + query: String, 1579 + parameter: Int, 1580 + context: Context, 1581 + names: Dict(Int, List(Name)), 1582 + ) -> Dict(Int, List(Name)) { 1583 + case context { 1584 + NoIdentifier | Identifier(_) | QueryParameter(_) -> 1585 + parameter_names_loop(splitters, query, QueryParameter(parameter), names) 1586 + 1587 + // We've found something like `$1 = $2` 1588 + QueryParameterEquality(_) -> 1589 + parameter_names_loop(splitters, query, NoIdentifier, names) 1590 + 1591 + // We've found something like `wibble = $1`, we have to keep track of 1592 + // this new name we've found for the parameter we've just parsed. 1593 + IdentifierEquality(identifier) -> { 1594 + let names = add_name(names, parameter, identifier) 1595 + parameter_names_loop(splitters, query, NoIdentifier, names) 1596 + } 1597 + } 1598 + } 1599 + 1600 + fn recover_from_error( 1601 + splitters: Splitters, 1602 + query: String, 1603 + names: Dict(Int, List(Name)), 1604 + ) -> Dict(Int, List(Name)) { 1605 + let #(_, _, query) = splitter.split(splitters.whitespace, query) 1606 + parameter_names_loop(splitters, query, NoIdentifier, names) 1607 + }
+158
test/squirrel_test.gleam
··· 877 877 squirrel.compare_code_snippets(expected_code, actual_code) 878 878 } 879 879 880 + // --- PARAMETER NAME INFERENCE ------------------------------------------------ 881 + // This is to make sure we can infer names correctly from queries, and that we 882 + // generate proper code for those. 883 + // 884 + 885 + pub fn can_infer_simple_name_on_left_of_equality_test() { 886 + should_codegen( 887 + " 888 + with squirrel_user as (select 1 as id, 'Louis' as name) 889 + select name from squirrel_user where id = $1 890 + ", 891 + ) 892 + |> birdie.snap(title: "can infer simple name on left of equality") 893 + } 894 + 895 + pub fn can_infer_simple_quoted_name_on_left_of_equality_test() { 896 + should_codegen( 897 + " 898 + with squirrel_user as (select 1 as \"squirrel id\", 'Louis' as name) 899 + select name from squirrel_user where \"squirrel id\" = $1 900 + ", 901 + ) 902 + |> birdie.snap(title: "can infer simple quoted name on left of equality") 903 + } 904 + 905 + pub fn can_infer_simple_name_on_right_of_equality_test() { 906 + should_codegen( 907 + " 908 + with squirrel_user as (select 1 as id, 'Louis' as name) 909 + select name from squirrel_user where $1 = id 910 + ", 911 + ) 912 + |> birdie.snap(title: "can infer simple name on right of equality") 913 + } 914 + 915 + pub fn can_infer_simple_quoted_name_on_right_of_equality_test() { 916 + should_codegen( 917 + " 918 + with squirrel_user as (select 1 as \"squirrel id\", 'Louis' as name) 919 + select name from squirrel_user where $1 = \"squirrel id\" 920 + ", 921 + ) 922 + |> birdie.snap(title: "can infer simple quoted name on right of equality") 923 + } 924 + 925 + pub fn can_infer_table_access_on_left_of_equality_test() { 926 + should_codegen( 927 + " 928 + with squirrel_user as (select 1 as id, 'Louis' as name) 929 + select name from squirrel_user where squirrel_user.id = $1 930 + ", 931 + ) 932 + |> birdie.snap(title: "can infer table access on left of equality") 933 + } 934 + 935 + pub fn can_infer_quoted_table_access_on_left_of_equality_test() { 936 + should_codegen( 937 + " 938 + with squirrel_user as (select 1 as \"special id\", 'Louis' as name) 939 + select name from squirrel_user where squirrel_user.\"special id\" = $1 940 + ", 941 + ) 942 + |> birdie.snap(title: "can infer quoted table access on left of equality") 943 + } 944 + 945 + pub fn can_infer_table_access_on_right_of_equality_test() { 946 + should_codegen( 947 + " 948 + with squirrel_user as (select 1 as id, 'Louis' as name) 949 + select name from squirrel_user where $1 = squirrel_user.id 950 + ", 951 + ) 952 + |> birdie.snap(title: "can infer table access on right of equality") 953 + } 954 + 955 + pub fn can_infer_quoted_table_access_on_right_of_equality_test() { 956 + should_codegen( 957 + " 958 + with squirrel_user as (select 1 as \"special id\", 'Louis' as name) 959 + select name from squirrel_user where $1 = squirrel_user.\"special id\" 960 + ", 961 + ) 962 + |> birdie.snap(title: "can infer quoted table access on right of equality") 963 + } 964 + 965 + pub fn can_infer_multiple_arguments_test() { 966 + should_codegen( 967 + " 968 + with squirrel_user as (select 1 as squirrel_user_id, 'Louis' as name) 969 + select name 970 + from squirrel_user 971 + where $1 = squirrel_user_id 972 + and squirrel_user.name = $2 973 + ", 974 + ) 975 + |> birdie.snap(title: "can infer multiple arguments") 976 + } 977 + 978 + pub fn comments_are_ignored_when_inferring_parameters_names_test() { 979 + should_codegen( 980 + " 981 + with squirrel_user as (select 1 as squirrel_user_id, 'Louis' as name) 982 + select name 983 + from squirrel_user 984 + -- $1 = id 985 + where $1 = name 986 + -- $1 = id 987 + ", 988 + ) 989 + |> birdie.snap(title: "comments are ignored when inferring parameters names") 990 + } 991 + 992 + pub fn multiline_comments_are_ignored_when_inferring_parameters_names_test() { 993 + should_codegen( 994 + " 995 + with squirrel_user as (select 1 as squirrel_user_id, 'Louis' as name) 996 + select name 997 + from squirrel_user 998 + /* $1 = id */ 999 + where $1 = name 1000 + /* $1 = id */ 1001 + ", 1002 + ) 1003 + |> birdie.snap( 1004 + title: "multiline comments are ignored when inferring parameters names", 1005 + ) 1006 + } 1007 + 1008 + pub fn multiline_nested_comments_are_ignored_when_inferring_parameters_names_test() { 1009 + should_codegen( 1010 + " 1011 + with squirrel_user as (select 1 as squirrel_user_id, 'Louis' as name) 1012 + select name 1013 + from squirrel_user 1014 + /* $1 = id /* $1 = id */ */ 1015 + where $1 = name 1016 + /* $1 = id /* $1 = id */ */ 1017 + ", 1018 + ) 1019 + |> birdie.snap( 1020 + title: "multiline nested comments are ignored when inferring parameters names", 1021 + ) 1022 + } 1023 + 1024 + pub fn strings_are_ignored_when_inferring_parameters_names_test() { 1025 + should_codegen( 1026 + " 1027 + with squirrel_user as (select 1 as squirrel_user_id, 'Louis' as name) 1028 + select name 1029 + from squirrel_user 1030 + where $1 = '$1 = id' 1031 + and $1 = name 1032 + and $1 = '$1 = id' 1033 + ", 1034 + ) 1035 + |> birdie.snap(title: "strings are ignored when inferring parameters names") 1036 + } 1037 + 880 1038 // --- REGRESSIONS ------------------------------------------------------------- 881 1039 // Bugs reported from GitHub issues so I make sure those will no longer pop up. 882 1040 //