Skip to content

Commit 39e0271

Browse files
Enums & Literals as map keys (#1178)
Issue #1050 Added support for enums and literal strings in map keys: ```baml enum MapKey { A B C } class Fields { // Enum as key e map<MapKey, string> // Single literal as key l1 map<"literal", string> // Union of literals as keys l2 map<"one" | "two" | ("three" | "four"), string> } ``` Literal integers are more complicated since they require maps to support int keys. See #1180. <!-- ELLIPSIS_HIDDEN --> ---- > [!IMPORTANT] > Add support for enums and literal strings as map keys in BAML, with updated validation, coercion logic, and tests. > > - **Behavior**: > - Support for enums and literal strings as map keys added in `mod.rs` and `types.rs`. > - Validation logic updated to allow enums and literal strings as map keys. > - Coercion logic updated to handle enums and literal strings as map keys. > - **Tests**: > - Added tests in `map_enums_and_literals.baml` and `map_types.baml` to verify new map key functionality. > - Updated `test_functions.py` and `integ-tests.test.ts` to include cases for enum and literal string map keys. > - **Misc**: > - Updated error messages in `error.rs` to reflect new map key types. > - Minor updates in `async_client.py`, `sync_client.py`, and `client.rb` to support new map key types. > > <sup>This description was created by </sup>[<img alt="Ellipsis" src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=BoundaryML%2Fbaml&utm_source=github&utm_medium=referral)<sup> for c7742fd. It will automatically update as commits are pushed.</sup> <!-- ELLIPSIS_HIDDEN -->
1 parent fcdbdfb commit 39e0271

File tree

35 files changed

+1210
-113
lines changed

35 files changed

+1210
-113
lines changed

engine/baml-lib/baml-core/src/ir/ir_helpers/mod.rs

+7-1
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,13 @@ impl IRHelper for IntermediateRepr {
278278
match maybe_item_type {
279279
Some(item_type) => {
280280
let map_type = FieldType::Map(
281-
Box::new(FieldType::Primitive(TypeValue::String)),
281+
Box::new(match &field_type {
282+
FieldType::Map(key, _) => match key.as_ref() {
283+
FieldType::Enum(name) => FieldType::Enum(name.clone()),
284+
_ => FieldType::string(),
285+
},
286+
_ => FieldType::string(),
287+
}),
282288
Box::new(item_type.clone()),
283289
);
284290

engine/baml-lib/baml-core/src/validate/validation_pipeline/validations/types.rs

+49-5
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
use baml_types::TypeValue;
1+
use std::collections::VecDeque;
2+
3+
use baml_types::{LiteralValue, TypeValue};
4+
use either::Either;
25
use internal_baml_diagnostics::{DatamodelError, DatamodelWarning, Span};
36
use internal_baml_schema_ast::ast::{
47
Argument, Attribute, Expression, FieldArity, FieldType, Identifier, WithName, WithSpan,
@@ -56,12 +59,53 @@ fn validate_type_allowed(ctx: &mut Context<'_>, field_type: &FieldType) {
5659
field_type.span().clone(),
5760
));
5861
}
62+
5963
match &kv_types.0 {
64+
// String key.
6065
FieldType::Primitive(FieldArity::Required, TypeValue::String, ..) => {}
61-
key_type => {
62-
ctx.push_error(DatamodelError::new_validation_error(
63-
"Maps may only have strings as keys",
64-
key_type.span().clone(),
66+
67+
// Enum key.
68+
FieldType::Symbol(FieldArity::Required, identifier, _)
69+
if ctx
70+
.db
71+
.find_type(identifier)
72+
.is_some_and(|t| matches!(t, Either::Right(_))) => {}
73+
74+
// Literal string key.
75+
FieldType::Literal(FieldArity::Required, LiteralValue::String(_), ..) => {}
76+
77+
// Literal string union.
78+
FieldType::Union(FieldArity::Required, items, ..) => {
79+
let mut queue = VecDeque::from_iter(items.iter());
80+
81+
while let Some(item) = queue.pop_front() {
82+
match item {
83+
// Ok, literal string.
84+
FieldType::Literal(
85+
FieldArity::Required,
86+
LiteralValue::String(_),
87+
..,
88+
) => {}
89+
90+
// Nested union, "recurse" but it's iterative.
91+
FieldType::Union(FieldArity::Required, nested, ..) => {
92+
queue.extend(nested.iter());
93+
}
94+
95+
other => {
96+
ctx.push_error(
97+
DatamodelError::new_type_not_allowed_as_map_key_error(
98+
other.span().clone(),
99+
),
100+
);
101+
}
102+
}
103+
}
104+
}
105+
106+
other => {
107+
ctx.push_error(DatamodelError::new_type_not_allowed_as_map_key_error(
108+
other.span().clone(),
65109
));
66110
}
67111
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
enum MapKey {
2+
A
3+
B
4+
C
5+
}
6+
7+
class Fields {
8+
e map<MapKey, string>
9+
l1 map<"literal", string>
10+
l2 map<"one" | "two" | ("three" | "four"), string>
11+
}
12+
13+
function InOutEnumKey(i1: map<MapKey, string>, i2: map<MapKey, string>) -> map<MapKey, string> {
14+
client "openai/gpt-4o"
15+
prompt #"
16+
Merge these: {{i1}} {{i2}}
17+
18+
{{ ctx.output_format }}
19+
"#
20+
}
21+
22+
function InOutLiteralStringUnionMapKey(
23+
i1: map<"one" | "two" | ("three" | "four"), string>,
24+
i2: map<"one" | "two" | ("three" | "four"), string>
25+
) -> map<"one" | "two" | ("three" | "four"), string> {
26+
client "openai/gpt-4o"
27+
prompt #"
28+
Merge these:
29+
30+
{{i1}}
31+
32+
{{i2}}
33+
34+
{{ ctx.output_format }}
35+
"#
36+
}

engine/baml-lib/baml/tests/validation_files/class/map_types.baml

+14-8
Original file line numberDiff line numberDiff line change
@@ -31,49 +31,55 @@ function InputAndOutput(i1: map<string, string>, i2: map<MapDummy, string>) -> m
3131
"#
3232
}
3333

34-
// error: Error validating: Maps may only have strings as keys
34+
// error: Error validating: Maps may only have strings, enums or literal strings as keys
3535
// --> class/map_types.baml:16
3636
// |
3737
// 15 |
3838
// 16 | b1 map<int, string>
3939
// |
40-
// error: Error validating: Maps may only have strings as keys
40+
// error: Error validating: Maps may only have strings, enums or literal strings as keys
4141
// --> class/map_types.baml:17
4242
// |
4343
// 16 | b1 map<int, string>
4444
// 17 | b2 map<float, string>
4545
// |
46-
// error: Error validating: Maps may only have strings as keys
46+
// error: Error validating: Maps may only have strings, enums or literal strings as keys
4747
// --> class/map_types.baml:18
4848
// |
4949
// 17 | b2 map<float, string>
5050
// 18 | b3 map<MapDummy, string>
5151
// |
52-
// error: Error validating: Maps may only have strings as keys
52+
// error: Error validating: Maps may only have strings, enums or literal strings as keys
5353
// --> class/map_types.baml:19
5454
// |
5555
// 18 | b3 map<MapDummy, string>
5656
// 19 | b4 map<string?, string>
5757
// |
58-
// error: Error validating: Maps may only have strings as keys
58+
// error: Error validating: Maps may only have strings, enums or literal strings as keys
5959
// --> class/map_types.baml:20
6060
// |
6161
// 19 | b4 map<string?, string>
6262
// 20 | b5 map<string | int, string>
6363
// |
64-
// error: Error validating: Maps may only have strings as keys
64+
// error: Error validating: Maps may only have strings, enums or literal strings as keys
65+
// --> class/map_types.baml:20
66+
// |
67+
// 19 | b4 map<string?, string>
68+
// 20 | b5 map<string | int, string>
69+
// |
70+
// error: Error validating: Maps may only have strings, enums or literal strings as keys
6571
// --> class/map_types.baml:23
6672
// |
6773
// 22 | c1 string | map<string, string>
6874
// 23 | c2 string | map<int, string>
6975
// |
70-
// error: Error validating: Maps may only have strings as keys
76+
// error: Error validating: Maps may only have strings, enums or literal strings as keys
7177
// --> class/map_types.baml:24
7278
// |
7379
// 23 | c2 string | map<int, string>
7480
// 24 | c3 string | map<string?, string>
7581
// |
76-
// error: Error validating: Maps may only have strings as keys
82+
// error: Error validating: Maps may only have strings, enums or literal strings as keys
7783
// --> class/map_types.baml:27
7884
// |
7985
// 26 |

engine/baml-lib/baml/tests/validation_files/class/secure_types.baml

+8-8
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ class ComplexTypes {
2525
// 2 | class ComplexTypes {
2626
// 3 | a map<string[], (int | bool[]) | apple_pie[][]>
2727
// |
28-
// error: Error validating: Maps may only have strings as keys
28+
// error: Error validating: Maps may only have strings, enums or literal strings as keys
2929
// --> class/secure_types.baml:3
3030
// |
3131
// 2 | class ComplexTypes {
@@ -43,7 +43,7 @@ class ComplexTypes {
4343
// 3 | a map<string[], (int | bool[]) | apple_pie[][]>
4444
// 4 | b (int, map<bool, string?>, (char | float)[][] | long_word_123.foobar[])
4545
// |
46-
// error: Error validating: Maps may only have strings as keys
46+
// error: Error validating: Maps may only have strings, enums or literal strings as keys
4747
// --> class/secure_types.baml:4
4848
// |
4949
// 3 | a map<string[], (int | bool[]) | apple_pie[][]>
@@ -73,7 +73,7 @@ class ComplexTypes {
7373
// 5 | c apple123_456_pie | (stringer, bool[], (int | char))[]
7474
// 6 | d map<int[][], ((int | float) | char[])>
7575
// |
76-
// error: Error validating: Maps may only have strings as keys
76+
// error: Error validating: Maps may only have strings, enums or literal strings as keys
7777
// --> class/secure_types.baml:6
7878
// |
7979
// 5 | c apple123_456_pie | (stringer, bool[], (int | char))[]
@@ -121,7 +121,7 @@ class ComplexTypes {
121121
// 9 | g (int, (float, char, bool), string[]) | tuple_inside_tuple[]
122122
// 10 | h (((int | string)[]) | map<bool[][], char[]>)
123123
// |
124-
// error: Error validating: Maps may only have strings as keys
124+
// error: Error validating: Maps may only have strings, enums or literal strings as keys
125125
// --> class/secure_types.baml:10
126126
// |
127127
// 9 | g (int, (float, char, bool), string[]) | tuple_inside_tuple[]
@@ -181,13 +181,13 @@ class ComplexTypes {
181181
// 12 | j ((char, int[][], (bool | string[][])) | double[][][][], (float, int)[])
182182
// 13 | k map<string[], (int | long[])> | map<float[][], double[][]>
183183
// |
184-
// error: Error validating: Maps may only have strings as keys
184+
// error: Error validating: Maps may only have strings, enums or literal strings as keys
185185
// --> class/secure_types.baml:13
186186
// |
187187
// 12 | j ((char, int[][], (bool | string[][])) | double[][][][], (float, int)[])
188188
// 13 | k map<string[], (int | long[])> | map<float[][], double[][]>
189189
// |
190-
// error: Error validating: Maps may only have strings as keys
190+
// error: Error validating: Maps may only have strings, enums or literal strings as keys
191191
// --> class/secure_types.baml:13
192192
// |
193193
// 12 | j ((char, int[][], (bool | string[][])) | double[][][][], (float, int)[])
@@ -247,13 +247,13 @@ class ComplexTypes {
247247
// 15 | m (tuple_1, tuple_2 | tuple_3, (tuple_4, tuple_5))[]
248248
// 16 | n map<complex_key_type[], map<another_key, (int | string[])>>
249249
// |
250-
// error: Error validating: Maps may only have strings as keys
250+
// error: Error validating: Maps may only have strings, enums or literal strings as keys
251251
// --> class/secure_types.baml:16
252252
// |
253253
// 15 | m (tuple_1, tuple_2 | tuple_3, (tuple_4, tuple_5))[]
254254
// 16 | n map<complex_key_type[], map<another_key, (int | string[])>>
255255
// |
256-
// error: Error validating: Maps may only have strings as keys
256+
// error: Error validating: Maps may only have strings, enums or literal strings as keys
257257
// --> class/secure_types.baml:16
258258
// |
259259
// 15 | m (tuple_1, tuple_2 | tuple_3, (tuple_4, tuple_5))[]

engine/baml-lib/diagnostics/src/error.rs

+7
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,13 @@ impl DatamodelError {
594594
Self::new(msg, span)
595595
}
596596

597+
pub fn new_type_not_allowed_as_map_key_error(span: Span) -> DatamodelError {
598+
Self::new_validation_error(
599+
"Maps may only have strings, enums or literal strings as keys",
600+
span,
601+
)
602+
}
603+
597604
pub fn span(&self) -> &Span {
598605
&self.span
599606
}

engine/baml-lib/jsonish/src/deserializer/coercer/coerce_map.rs

+70-13
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
1+
use std::collections::VecDeque;
2+
13
use anyhow::Result;
24

3-
use crate::deserializer::{
4-
deserialize_flags::{DeserializerConditions, Flag},
5-
types::BamlValueWithFlags,
5+
use crate::{
6+
deserializer::{
7+
deserialize_flags::{DeserializerConditions, Flag},
8+
types::BamlValueWithFlags,
9+
},
10+
jsonish,
611
};
7-
use baml_types::{BamlMap, FieldType, TypeValue};
12+
use baml_types::{BamlMap, FieldType, LiteralValue, TypeValue};
813

914
use super::{ParsingContext, ParsingError, TypeCoercer};
1015

1116
pub(super) fn coerce_map(
1217
ctx: &ParsingContext,
1318
map_target: &FieldType,
14-
value: Option<&crate::jsonish::Value>,
19+
value: Option<&jsonish::Value>,
1520
) -> Result<BamlValueWithFlags, ParsingError> {
1621
log::debug!(
1722
"scope: {scope} :: coercing to: {name} (current: {current})",
@@ -28,22 +33,74 @@ pub(super) fn coerce_map(
2833
return Err(ctx.error_unexpected_type(map_target, value));
2934
};
3035

31-
if !matches!(**key_type, FieldType::Primitive(TypeValue::String)) {
32-
return Err(ctx.error_map_must_have_string_key(key_type));
36+
// TODO: Do we actually need to check the key type here in the coercion
37+
// logic? Can the user pass a "type" here at runtime? Can we pass the wrong
38+
// type from our own code or is this guaranteed to be a valid map key type?
39+
// If we can determine that the type is always valid then we can get rid of
40+
// this logic and skip the loops & allocs in the the union branch.
41+
match key_type.as_ref() {
42+
// String, enum or just one literal string, OK.
43+
FieldType::Primitive(TypeValue::String)
44+
| FieldType::Enum(_)
45+
| FieldType::Literal(LiteralValue::String(_)) => {}
46+
47+
// For unions we need to check if all the items are literal strings.
48+
FieldType::Union(items) => {
49+
let mut queue = VecDeque::from_iter(items.iter());
50+
while let Some(item) = queue.pop_front() {
51+
match item {
52+
FieldType::Literal(LiteralValue::String(_)) => continue,
53+
FieldType::Union(nested) => queue.extend(nested.iter()),
54+
other => return Err(ctx.error_map_must_have_supported_key(other)),
55+
}
56+
}
57+
}
58+
59+
// Key type not allowed.
60+
other => return Err(ctx.error_map_must_have_supported_key(other)),
3361
}
3462

3563
let mut flags = DeserializerConditions::new();
3664
flags.add_flag(Flag::ObjectToMap(value.clone()));
3765

3866
match &value {
39-
crate::jsonish::Value::Object(obj) => {
67+
jsonish::Value::Object(obj) => {
4068
let mut items = BamlMap::new();
41-
for (key, value) in obj.iter() {
42-
match value_type.coerce(&ctx.enter_scope(key), value_type, Some(value)) {
43-
Ok(v) => {
44-
items.insert(key.clone(), (DeserializerConditions::new(), v));
69+
for (idx, (key, value)) in obj.iter().enumerate() {
70+
let coerced_value =
71+
match value_type.coerce(&ctx.enter_scope(key), value_type, Some(value)) {
72+
Ok(v) => v,
73+
Err(e) => {
74+
flags.add_flag(Flag::MapValueParseError(key.clone(), e));
75+
// Could not coerce value, nothing else to do here.
76+
continue;
77+
}
78+
};
79+
80+
// Keys are just strings but since we suport enums and literals
81+
// we have to check that the key we are reading is actually a
82+
// valid enum member or expected literal value. The coercion
83+
// logic already does that so we'll just coerce the key.
84+
//
85+
// TODO: Is it necessary to check that values match here? This
86+
// is also checked at `coerce_arg` in
87+
// baml-lib/baml-core/src/ir/ir_helpers/to_baml_arg.rs
88+
let key_as_jsonish = jsonish::Value::String(key.to_owned());
89+
match key_type.coerce(ctx, &key_type, Some(&key_as_jsonish)) {
90+
Ok(_) => {
91+
// Hack to avoid cloning the key twice.
92+
let jsonish::Value::String(owned_key) = key_as_jsonish else {
93+
unreachable!("key_as_jsonish is defined as jsonish::Value::String");
94+
};
95+
96+
// Both the value and the key were successfully
97+
// coerced, add the key to the map.
98+
items.insert(owned_key, (DeserializerConditions::new(), coerced_value));
4599
}
46-
Err(e) => flags.add_flag(Flag::MapValueParseError(key.clone(), e)),
100+
// Couldn't coerce key, this is either not a valid enum
101+
// variant or it doesn't match any of the literal values
102+
// expected.
103+
Err(e) => flags.add_flag(Flag::MapKeyParseError(idx, e)),
47104
}
48105
}
49106
Ok(BamlValueWithFlags::Map(flags, items))

engine/baml-lib/jsonish/src/deserializer/coercer/mod.rs

+4-2
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,11 @@ impl ParsingContext<'_> {
139139
}
140140
}
141141

142-
pub(crate) fn error_map_must_have_string_key(&self, key_type: &FieldType) -> ParsingError {
142+
pub(crate) fn error_map_must_have_supported_key(&self, key_type: &FieldType) -> ParsingError {
143143
ParsingError {
144-
reason: format!("Maps may only have strings for keys, but got {}", key_type),
144+
reason: format!(
145+
"Maps may only have strings, enums or literal strings for keys, but got {key_type}"
146+
),
145147
scope: self.scope.clone(),
146148
causes: vec![],
147149
}

engine/baml-lib/parser-database/src/attributes/constraint.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ pub(super) fn visit_constraint_attributes(
2323
ctx.push_error(DatamodelError::new_attribute_validation_error(
2424
"Internal error - the parser should have ruled out other attribute names.",
2525
other_name,
26-
span
26+
span,
2727
));
2828
return ();
2929
}

0 commit comments

Comments
 (0)