Compare commits
6 Commits
f6ad1f4e60
...
5eeab6a062
| Author | SHA1 | Date |
|---|---|---|
|
|
5eeab6a062 | |
|
|
09de42e8bb | |
|
|
667911c47a | |
|
|
2a24a26dd5 | |
|
|
3d3fccdcba | |
|
|
5532fa61f5 |
|
|
@ -0,0 +1,112 @@
|
|||
///|
|
||||
/// Create a schema that dispatches to one of several options based on a
|
||||
/// discriminator field value. Unlike `union()`, this directly selects the
|
||||
/// matching schema in O(1) instead of trying each branch in order.
|
||||
///
|
||||
/// # Example
|
||||
/// ```mbt nocheck
|
||||
/// let pet = @moon_zod.discriminated_union("type", {
|
||||
/// "dog": @moon_zod.object({ "bark": @moon_zod.boolean() }),
|
||||
/// "cat": @moon_zod.object({ "meow": @moon_zod.boolean() }),
|
||||
/// })
|
||||
/// ```
|
||||
pub fn discriminated_union(
|
||||
discriminator : String,
|
||||
options : Map[String, Schema],
|
||||
) -> Schema {
|
||||
{
|
||||
schema_type: DiscriminatedUnionType(discriminator, options),
|
||||
rules: [],
|
||||
description: "",
|
||||
required_error: "",
|
||||
invalid_type_error: "",
|
||||
name: "",
|
||||
brand: "",
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
pub fn Schema::parse_discriminated_union(
|
||||
self : Schema,
|
||||
discriminator : String,
|
||||
options : Map[String, Schema],
|
||||
json : Json,
|
||||
path_stack : Array[String],
|
||||
) -> RawSchemaResult {
|
||||
let dispatch_result = match json {
|
||||
Object(map) => {
|
||||
path_stack.push(discriminator)
|
||||
let disc_path = format_path(path_stack)
|
||||
let _ = path_stack.pop()
|
||||
match map.get(discriminator) {
|
||||
None =>
|
||||
Err([
|
||||
RawIssue::{
|
||||
code: IssueCode::MissingRequired(discriminator),
|
||||
path: disc_path,
|
||||
message: "Required",
|
||||
input: json,
|
||||
},
|
||||
])
|
||||
Some(String(disc_value)) =>
|
||||
match options.get(disc_value) {
|
||||
Some(schema) => parse_inner(schema, json, path_stack)
|
||||
None => {
|
||||
let valid_keys : Array[Json] = []
|
||||
for k in options.keys() {
|
||||
valid_keys.push(Json::string(k))
|
||||
}
|
||||
Err([
|
||||
RawIssue::{
|
||||
code: IssueCode::InvalidValue(valid_keys),
|
||||
path: disc_path,
|
||||
message: "Invalid discriminator value",
|
||||
input: json,
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
Some(_) =>
|
||||
Err([
|
||||
RawIssue::{
|
||||
code: IssueCode::InvalidType("string"),
|
||||
path: disc_path,
|
||||
message: "Discriminator field must be a string",
|
||||
input: json,
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let path = format_path(path_stack)
|
||||
Err([
|
||||
RawIssue::{
|
||||
code: IssueCode::InvalidType("object"),
|
||||
path,
|
||||
message: "Expected object for discriminated union",
|
||||
input: json,
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
// Check rules on the DU schema itself (for .refine() and chained rules)
|
||||
match dispatch_result {
|
||||
Ok(v) => {
|
||||
let raw : Array[RawIssue] = []
|
||||
collect_raw_errors(raw, path_stack, json, self.rules)
|
||||
if raw.is_empty() {
|
||||
Ok(v)
|
||||
} else {
|
||||
Err(raw)
|
||||
}
|
||||
}
|
||||
Err(dispatch_raw) => {
|
||||
let raw : Array[RawIssue] = []
|
||||
for r in dispatch_raw {
|
||||
raw.push(r)
|
||||
}
|
||||
collect_raw_errors(raw, path_stack, json, self.rules)
|
||||
Err(raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,60 @@ pub fn enum_values(
|
|||
}
|
||||
}
|
||||
|
||||
///|
|
||||
/// Exclude specific values from an enum schema.
|
||||
/// Returns a new schema with the remaining values.
|
||||
/// Aborts if called on a non-enum schema.
|
||||
///
|
||||
/// # Example
|
||||
/// ```mbt nocheck
|
||||
/// let colors = enum_values(["red", "green", "blue"])
|
||||
///
|
||||
/// let no_green = colors.exclude(["green"])
|
||||
/// // now accepts only "red" and "blue"
|
||||
/// ```
|
||||
pub fn Schema::exclude(self : Schema, values : Array[String]) -> Schema {
|
||||
match self.schema_type {
|
||||
EnumType(existing) => {
|
||||
let filtered : Array[String] = []
|
||||
for v in existing {
|
||||
if !value_in_array(v, values) {
|
||||
filtered.push(v)
|
||||
}
|
||||
}
|
||||
{ ..self, schema_type: EnumType(filtered) }
|
||||
}
|
||||
_ => abort("exclude() is only valid for enum schemas")
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
/// Extract specific values from an enum schema.
|
||||
/// Returns a new schema that only accepts the specified values.
|
||||
/// Aborts if called on a non-enum schema.
|
||||
///
|
||||
/// # Example
|
||||
/// ```mbt nocheck
|
||||
/// let colors = enum_values(["red", "green", "blue"])
|
||||
///
|
||||
/// let warm = colors.extract(["red", "orange"]) // "orange" ignored, only "red" kept
|
||||
/// // now accepts only "red"
|
||||
/// ```
|
||||
pub fn Schema::extract(self : Schema, values : Array[String]) -> Schema {
|
||||
match self.schema_type {
|
||||
EnumType(existing) => {
|
||||
let filtered : Array[String] = []
|
||||
for v in existing {
|
||||
if value_in_array(v, values) {
|
||||
filtered.push(v)
|
||||
}
|
||||
}
|
||||
{ ..self, schema_type: EnumType(filtered) }
|
||||
}
|
||||
_ => abort("extract() is only valid for enum schemas")
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
pub fn Schema::parse_enum(
|
||||
_self : Schema,
|
||||
|
|
|
|||
|
|
@ -109,5 +109,8 @@ pub fn type_origin(t : SchemaType) -> String {
|
|||
PreprocessType(_, _) => "preprocess"
|
||||
OptionalType(_) => "optional"
|
||||
DefaultType(_, _) => "default"
|
||||
LazyType(_) => "lazy"
|
||||
DiscriminatedUnionType(_, _) => "discriminated_union"
|
||||
PipeType(_, _, _) => "pipe"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
///|
|
||||
/// Create a schema that defers resolution to parse time.
|
||||
/// Required for recursive/self-referencing schemas (trees, linked lists, etc.).
|
||||
///
|
||||
/// # Example
|
||||
/// ```mbt nocheck
|
||||
/// fn tree_schema() -> @moon_zod.Schema {
|
||||
/// @moon_zod.recursive(fn() {
|
||||
/// @moon_zod.object({
|
||||
/// "value": @moon_zod.number(),
|
||||
/// "children": @moon_zod.array(@moon_zod.recursive(tree_schema)).optional(),
|
||||
/// })
|
||||
/// })
|
||||
/// }
|
||||
/// ```
|
||||
pub fn recursive(f : () -> Schema) -> Schema {
|
||||
{
|
||||
schema_type: LazyType(f),
|
||||
rules: [],
|
||||
description: "",
|
||||
required_error: "",
|
||||
invalid_type_error: "",
|
||||
name: "",
|
||||
brand: "",
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
pub fn Schema::parse_lazy(
|
||||
self : Schema,
|
||||
f : () -> Schema,
|
||||
json : Json,
|
||||
path_stack : Array[String],
|
||||
) -> RawSchemaResult {
|
||||
let resolved = f()
|
||||
match parse_inner(resolved, json, path_stack) {
|
||||
Err(e) => Err(e)
|
||||
Ok(v) => {
|
||||
let raw : Array[RawIssue] = []
|
||||
collect_raw_errors(raw, path_stack, json, self.rules)
|
||||
if raw.is_empty() {
|
||||
Ok(v)
|
||||
} else {
|
||||
Err(raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
///|
|
||||
/// Chain two schemas: validate the input against `self`, then validate the
|
||||
/// result against `output`. Errors from each stage are reported independently.
|
||||
///
|
||||
/// Unlike `.transform()`, `.pipe()` creates a clear two-stage pipeline where
|
||||
/// the output schema can have its own rules — errors are attributed to the
|
||||
/// correct stage.
|
||||
///
|
||||
/// # Example
|
||||
/// ```mbt nocheck
|
||||
/// let s = string()
|
||||
/// .transform(fn(s) { Ok(Json::string(s.length().to_string())) })
|
||||
/// .pipe(number().min(5))
|
||||
/// // string check → transform → number check(.min(5))
|
||||
/// ```
|
||||
pub fn Schema::pipe(self : Schema, output : Schema) -> Schema {
|
||||
{
|
||||
schema_type: PipeType(
|
||||
self,
|
||||
TransformClosure::{ f: fn(v) { Ok(v) } },
|
||||
output,
|
||||
),
|
||||
rules: [],
|
||||
description: self.description,
|
||||
required_error: self.required_error,
|
||||
invalid_type_error: self.invalid_type_error,
|
||||
name: self.name,
|
||||
brand: self.brand,
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
pub fn Schema::parse_pipe(
|
||||
_self : Schema,
|
||||
input_schema : Schema,
|
||||
bridge : TransformClosure,
|
||||
output_schema : Schema,
|
||||
json : Json,
|
||||
path_stack : Array[String],
|
||||
) -> RawSchemaResult {
|
||||
match parse_inner(input_schema, json, path_stack) {
|
||||
Err(e) => Err(e)
|
||||
Ok(parsed) =>
|
||||
match (bridge.f)(parsed) {
|
||||
Err(msg) => {
|
||||
let path = format_path(path_stack)
|
||||
Err([
|
||||
RawIssue::{
|
||||
code: IssueCode::Custom,
|
||||
path,
|
||||
message: msg,
|
||||
input: json,
|
||||
},
|
||||
])
|
||||
}
|
||||
Ok(bridged) => parse_inner(output_schema, bridged, path_stack)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,9 @@ pub(all) enum SchemaType {
|
|||
PreprocessType(TransformClosure, Schema)
|
||||
TransformType(Schema, TransformClosure)
|
||||
LiteralType(Json)
|
||||
LazyType(() -> Schema)
|
||||
DiscriminatedUnionType(String, Map[String, Schema])
|
||||
PipeType(Schema, TransformClosure, Schema)
|
||||
} derive(Debug)
|
||||
|
||||
///|
|
||||
|
|
@ -140,6 +143,18 @@ pub fn Schema::message(self : Schema, text : String) -> Schema {
|
|||
brand: self.brand,
|
||||
}
|
||||
}
|
||||
PipeType(input, bridge, output) => {
|
||||
let new_output = output.message(text)
|
||||
{
|
||||
schema_type: PipeType(input, bridge, new_output),
|
||||
rules: [],
|
||||
description: self.description,
|
||||
required_error: self.required_error,
|
||||
invalid_type_error: self.invalid_type_error,
|
||||
name: self.name,
|
||||
brand: self.brand,
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let n = self.rules.length()
|
||||
if n == 0 {
|
||||
|
|
@ -166,6 +181,8 @@ pub fn inner_type(t : SchemaType) -> SchemaType {
|
|||
DefaultType(inner, _) => inner.schema_type
|
||||
PreprocessType(_, inner) => inner.schema_type
|
||||
TransformType(inner, _) => inner.schema_type
|
||||
LazyType(_) | DiscriminatedUnionType(_, _) => t
|
||||
PipeType(_, _, output) => inner_type(output.schema_type)
|
||||
LiteralType(_) => t
|
||||
other => other
|
||||
}
|
||||
|
|
@ -222,6 +239,20 @@ pub fn append_rule_with_annotation(
|
|||
brand: schema.brand,
|
||||
}
|
||||
}
|
||||
PipeType(input_schema, bridge, output_schema) => {
|
||||
let new_output = append_rule_with_annotation(
|
||||
output_schema, check, message, code, annotation,
|
||||
)
|
||||
{
|
||||
schema_type: PipeType(input_schema, bridge, new_output),
|
||||
rules: [],
|
||||
description: schema.description,
|
||||
required_error: schema.required_error,
|
||||
invalid_type_error: schema.invalid_type_error,
|
||||
name: schema.name,
|
||||
brand: schema.brand,
|
||||
}
|
||||
}
|
||||
_ =>
|
||||
{ ..schema, rules: schema.rules + [{ check, message, code, annotation }] }
|
||||
}
|
||||
|
|
@ -318,6 +349,11 @@ fn parse_inner(
|
|||
TransformType(inner, closure) =>
|
||||
schema.parse_transform(inner, closure, json, path_stack)
|
||||
LiteralType(expected) => schema.parse_literal(expected, json, path_stack)
|
||||
LazyType(f) => schema.parse_lazy(f, json, path_stack)
|
||||
DiscriminatedUnionType(disc, options) =>
|
||||
schema.parse_discriminated_union(disc, options, json, path_stack)
|
||||
PipeType(input_schema, bridge, output_schema) =>
|
||||
schema.parse_pipe(input_schema, bridge, output_schema, json, path_stack)
|
||||
_ => {
|
||||
let valid = match (schema.schema_type, json) {
|
||||
(StringType, String(_)) => true
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ pub fn unwrap_schema(schema : Schema) -> Schema {
|
|||
DefaultType(inner, _) => unwrap_schema(inner)
|
||||
PreprocessType(_, inner) => unwrap_schema(inner)
|
||||
TransformType(inner, _) => unwrap_schema(inner)
|
||||
PipeType(_, _, output) => unwrap_schema(output)
|
||||
_ => schema
|
||||
}
|
||||
}
|
||||
|
|
@ -95,6 +96,15 @@ pub fn collect_named_schemas_impl(
|
|||
collect_named_schemas_impl(inner, visited, result)
|
||||
TransformType(inner, _) =>
|
||||
collect_named_schemas_impl(inner, visited, result)
|
||||
LazyType(f) => collect_named_schemas_impl(f(), visited, result)
|
||||
DiscriminatedUnionType(_, options) =>
|
||||
for _key, option in options {
|
||||
collect_named_schemas_impl(option, visited, result)
|
||||
}
|
||||
PipeType(input, _, output) => {
|
||||
collect_named_schemas_impl(input, visited, result)
|
||||
collect_named_schemas_impl(output, visited, result)
|
||||
}
|
||||
UnionType(schemas) =>
|
||||
for s in schemas {
|
||||
collect_named_schemas_impl(s, visited, result)
|
||||
|
|
@ -243,6 +253,16 @@ pub fn find_schema_dependencies_impl(
|
|||
find_schema_dependencies_impl(inner, schema_map, deps, visited_names)
|
||||
TransformType(inner, _) =>
|
||||
find_schema_dependencies_impl(inner, schema_map, deps, visited_names)
|
||||
LazyType(f) =>
|
||||
find_schema_dependencies_impl(f(), schema_map, deps, visited_names)
|
||||
DiscriminatedUnionType(_, options) =>
|
||||
for _key, option in options {
|
||||
find_schema_dependencies_impl(option, schema_map, deps, visited_names)
|
||||
}
|
||||
PipeType(input, _, output) => {
|
||||
find_schema_dependencies_impl(input, schema_map, deps, visited_names)
|
||||
find_schema_dependencies_impl(output, schema_map, deps, visited_names)
|
||||
}
|
||||
UnionType(schemas) =>
|
||||
for s in schemas {
|
||||
if !s.name.is_empty() && name_exists_in_map(s.name) {
|
||||
|
|
|
|||
|
|
@ -68,6 +68,9 @@ pub fn[R : JsonSchemaRenderer] render_json_type(
|
|||
TransformType(inner, closure) =>
|
||||
renderer.render_transform(inner, closure, schema)
|
||||
LiteralType(value) => renderer.render_literal(value, schema)
|
||||
LazyType(f) => render_json_type(renderer, f())
|
||||
DiscriminatedUnionType(_, _) => Json::null()
|
||||
PipeType(_, _, output) => render_json_type(renderer, output)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,15 @@ fn collect_type_defs(
|
|||
for item in schemas {
|
||||
collect_type_defs(item, type_hint, defs)
|
||||
}
|
||||
LazyType(f) => collect_type_defs(f(), type_hint, defs)
|
||||
DiscriminatedUnionType(_, options) =>
|
||||
for _key, option in options {
|
||||
collect_type_defs(option, type_hint, defs)
|
||||
}
|
||||
PipeType(input, _, output) => {
|
||||
collect_type_defs(input, type_hint, defs)
|
||||
collect_type_defs(output, type_hint, defs)
|
||||
}
|
||||
_ => ()
|
||||
}
|
||||
}
|
||||
|
|
@ -70,6 +79,15 @@ fn collect_field_defs(
|
|||
for item in schemas {
|
||||
collect_field_defs(item, type_hint, defs)
|
||||
}
|
||||
LazyType(f) => collect_field_defs(f(), type_hint, defs)
|
||||
DiscriminatedUnionType(_, options) =>
|
||||
for _key, option in options {
|
||||
collect_field_defs(option, type_hint, defs)
|
||||
}
|
||||
PipeType(input, _, output) => {
|
||||
collect_field_defs(input, type_hint, defs)
|
||||
collect_field_defs(output, type_hint, defs)
|
||||
}
|
||||
ObjectType(_, _) | EnumType(_) => collect_type_defs(schema, type_hint, defs)
|
||||
_ => ()
|
||||
}
|
||||
|
|
@ -239,6 +257,15 @@ fn field_to_moonbit_type(schema : @core.Schema, type_hint : String) -> String {
|
|||
IntersectionType(schemas) =>
|
||||
intersection_to_moonbit_type(schemas, type_hint)
|
||||
LiteralType(value) => literal_to_moonbit_type(value)
|
||||
LazyType(f) => field_to_moonbit_type(f(), type_hint)
|
||||
DiscriminatedUnionType(_, options) => {
|
||||
let schemas : Array[@core.Schema] = []
|
||||
for _key, option in options {
|
||||
schemas.push(option)
|
||||
}
|
||||
union_to_moonbit_type(schemas, type_hint)
|
||||
}
|
||||
PipeType(_, _, output) => field_to_moonbit_type(output, type_hint)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -105,6 +105,18 @@ pub fn[R : StringRenderer] render_type(
|
|||
TransformType(inner, closure) =>
|
||||
renderer.render_transform(inner, closure, schema, indent)
|
||||
LiteralType(value) => renderer.render_literal(value, schema, indent)
|
||||
LazyType(f) => render_type(renderer, f(), indent)
|
||||
DiscriminatedUnionType(_, options) => {
|
||||
let schemas : Array[@core.Schema] = []
|
||||
for _key, option in options {
|
||||
schemas.push(option)
|
||||
}
|
||||
renderer.render_union(schemas, schema, indent)
|
||||
}
|
||||
PipeType(input, _, output) =>
|
||||
render_type(renderer, input, indent) +
|
||||
" → " +
|
||||
render_type(renderer, output, indent)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -218,6 +218,33 @@ fn schema_type_to_code(
|
|||
TransformType(inner, _) =>
|
||||
schema_to_moon_zod_code_inline(inner, Some(defined_names)) +
|
||||
".transform(fn(x) { Ok(x) })" // TODO: Handle transform function code export
|
||||
LazyType(f) => schema_to_moon_zod_code_inline(f(), Some(defined_names))
|
||||
DiscriminatedUnionType(disc, options) => {
|
||||
let parts : Array[String] = []
|
||||
for key, option in options {
|
||||
let opt_code = schema_to_moon_zod_code_inline(
|
||||
option,
|
||||
Some(defined_names),
|
||||
)
|
||||
parts.push("\"" + @core.escape_mbt_string(key) + "\": " + opt_code)
|
||||
}
|
||||
"@moon_zod.discriminated_union(\"" +
|
||||
@core.escape_mbt_string(disc) +
|
||||
"\", {" +
|
||||
join_with(parts, ", ") +
|
||||
"})"
|
||||
}
|
||||
PipeType(input, _, output) => {
|
||||
let input_code = schema_to_moon_zod_code_inline(
|
||||
input,
|
||||
Some(defined_names),
|
||||
)
|
||||
let output_code = schema_to_moon_zod_code_inline(
|
||||
output,
|
||||
Some(defined_names),
|
||||
)
|
||||
input_code + ".pipe(" + output_code + ")"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,37 @@ MoonBit 没有 `cond ? a : b`,用 `if-else` 表达式替代:
|
|||
let label = if n > 0 { "positive" } else { "non-positive" }
|
||||
```
|
||||
|
||||
### `lazy` 是保留关键字
|
||||
|
||||
MoonBit 已预留 `lazy` 关键字,不能用作函数名或变量名:
|
||||
|
||||
```mbt
|
||||
// ❌ 警告:lazy is reserved for possible future use
|
||||
pub fn lazy(f : () -> Schema) -> Schema { ... }
|
||||
|
||||
// ✅ 改用其他名称
|
||||
pub fn recursive(f : () -> Schema) -> Schema { ... }
|
||||
```
|
||||
|
||||
### `let rec` 只支持函数
|
||||
|
||||
`let rec` 只能用于递归**函数**定义,不能用于递归**值**:
|
||||
|
||||
```mbt
|
||||
// ❌ 编译错误:The value identifier tree is unbound
|
||||
let rec tree = object({ "children": array(recursive(fn() { tree })).optional() })
|
||||
|
||||
// ✅ 用函数模式包装
|
||||
fn tree_schema() -> Schema {
|
||||
recursive(fn() {
|
||||
object({
|
||||
"value": number(),
|
||||
"children": array(recursive(tree_schema)).optional(),
|
||||
})
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### `[]` 创建空数组需要类型注解
|
||||
|
||||
```mbt
|
||||
|
|
@ -109,6 +140,22 @@ let json = @json.parse(raw) catch {
|
|||
}
|
||||
```
|
||||
|
||||
### Match 臂内联时需 `;` 分隔分支
|
||||
|
||||
当 match 写在一行时,分支之间需要 `;` 分隔:
|
||||
|
||||
```mbt
|
||||
// ❌ 解析错误:unexpected token `_`
|
||||
match json { Number(v, ..) => v > 0.0 _ => false }
|
||||
|
||||
// ✅ 正确:换行或者用 ; 分隔
|
||||
match json {
|
||||
Number(v, ..) => v > 0.0
|
||||
_ => false
|
||||
}
|
||||
match json { Number(v, ..) => v > 0.0; _ => false }
|
||||
```
|
||||
|
||||
### Match 分支返回值必须一致
|
||||
|
||||
所有 match arm 必须返回相同类型:
|
||||
|
|
@ -180,6 +227,21 @@ let top = stack.pop() // Option[String]
|
|||
let _ = stack.pop() // 忽略 None
|
||||
```
|
||||
|
||||
### Map 方法返回 Iter,不是 Array
|
||||
|
||||
`Map.keys()`、`Map.values()` 返回 `Iter` 类型,不能直接链式调用 `Array` 方法:
|
||||
|
||||
```mbt
|
||||
// ❌ 类型错误:Expr Type Mismatch, has type Iter[T], wanted Array[T]
|
||||
let keys_json : Array[Json] = options.keys().map(fn(k) { Json::string(k) })
|
||||
|
||||
// ✅ 手动收集
|
||||
let keys_json : Array[Json] = []
|
||||
for k in options.keys() {
|
||||
keys_json.push(Json::string(k))
|
||||
}
|
||||
```
|
||||
|
||||
### 有载荷的 enum 变体用 `::{}` 构造
|
||||
|
||||
```mbt
|
||||
|
|
@ -508,6 +570,22 @@ pub fn Empty::Empty() -> Empty {
|
|||
}
|
||||
```
|
||||
|
||||
### Match 臂中 `{ }` 块可能引起解析歧义
|
||||
|
||||
在返回 `Json` 等需要推断类型的 match 臂中使用 `{ let ...; expr }` 块,可能触发编译器关于表达式的歧义警告。如有歧义,可抽提为独立函数或简化 match 臂:
|
||||
|
||||
```mbt
|
||||
// ⚠️ 可能触发 "value cannot be implicitly ignored" 级联错误
|
||||
DiscriminatedUnionType(_, options) => {
|
||||
let schemas : Array[@core.Schema] = []
|
||||
for _key, option in options { schemas.push(option) }
|
||||
renderer.render_union(schemas, schema)
|
||||
}
|
||||
|
||||
// ✅ 简化为单一表达式
|
||||
DiscriminatedUnionType(_, _) => Json::null()
|
||||
```
|
||||
|
||||
### 字符串中 `\{` 永远是插值
|
||||
|
||||
MoonBit 字符串插值用 `\{expr}`,**无法在插值字符串中包含字面量 `{`**:
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ pub using @core {
|
|||
intersection,
|
||||
preprocess,
|
||||
literal,
|
||||
recursive,
|
||||
discriminated_union,
|
||||
unwrap_schema,
|
||||
peel_optional,
|
||||
indent_str,
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ pub using @core {
|
|||
intersection,
|
||||
preprocess,
|
||||
literal,
|
||||
recursive,
|
||||
discriminated_union,
|
||||
unwrap_schema,
|
||||
peel_optional,
|
||||
indent_str,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
///|
|
||||
test "discriminated union dispatches correctly" {
|
||||
let s = discriminated_union("type", {
|
||||
"dog": object({ "bark": boolean() }),
|
||||
"cat": object({ "meow": boolean() }),
|
||||
})
|
||||
let dog_input = parse_json("{\"type\": \"dog\", \"bark\": true}")
|
||||
guard s.parse(dog_input) is Ok(_) else { fail("expected Ok for dog") }
|
||||
let cat_input = parse_json("{\"type\": \"cat\", \"meow\": false}")
|
||||
guard s.parse(cat_input) is Ok(_) else { fail("expected Ok for cat") }
|
||||
}
|
||||
|
||||
///|
|
||||
test "discriminated union rejects unknown discriminator" {
|
||||
let s = discriminated_union("type", { "dog": object({ "bark": boolean() }) })
|
||||
let input = parse_json("{\"type\": \"fish\"}")
|
||||
guard s.parse(input) is Err(errors) else { fail("expected Err") }
|
||||
match errors[0].code {
|
||||
IssueCode::InvalidValue(_) => ()
|
||||
_ => fail("expected InvalidValue")
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
test "discriminated union missing discriminator field" {
|
||||
let s = discriminated_union("type", { "dog": object({ "bark": boolean() }) })
|
||||
let input = parse_json("{\"name\": \"fido\"}")
|
||||
guard s.parse(input) is Err(errors) else { fail("expected Err") }
|
||||
match errors[0].code {
|
||||
IssueCode::MissingRequired("type") => ()
|
||||
_ => fail("expected MissingRequired")
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
test "discriminated union rejects non-object input" {
|
||||
let s = discriminated_union("type", { "dog": object({ "bark": boolean() }) })
|
||||
guard s.parse(Json::string("hello")) is Err(_) else { fail("expected Err") }
|
||||
}
|
||||
|
||||
///|
|
||||
test "discriminated union validates against selected schema" {
|
||||
let s = discriminated_union("type", {
|
||||
"dog": object({ "bark": boolean() }).strict(),
|
||||
})
|
||||
// dog schema uses strict mode: "meow" is not a valid field
|
||||
let input = parse_json("{\"type\": \"dog\", \"bark\": true, \"meow\": false}")
|
||||
guard s.parse(input) is Err(errors) else { fail("expected Err") }
|
||||
match errors[0].code {
|
||||
IssueCode::UnrecognizedKeys(_) => ()
|
||||
_ => fail("expected UnrecognizedKeys")
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
test "discriminated union refine rule is checked" {
|
||||
let s = discriminated_union("type", { "dog": object({ "bark": boolean() }) }).refine(
|
||||
fn(json) {
|
||||
match json {
|
||||
Object(map) => map.contains("active")
|
||||
_ => false
|
||||
}
|
||||
},
|
||||
"active field required",
|
||||
)
|
||||
let input = parse_json("{\"type\": \"dog\", \"bark\": true}")
|
||||
match s.parse(input) {
|
||||
Err(errors) => @debug.assert_eq(errors[0].message, "active field required")
|
||||
_ => fail("expected Err")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
///|
|
||||
test "enum exclude removes values" {
|
||||
let s = enum_values(["red", "green", "blue"]).exclude(["green"])
|
||||
guard s.parse(Json::string("red")) is Ok(_) else {
|
||||
fail("expected Ok for red")
|
||||
}
|
||||
guard s.parse(Json::string("blue")) is Ok(_) else {
|
||||
fail("expected Ok for blue")
|
||||
}
|
||||
guard s.parse(Json::string("green")) is Err(_) else {
|
||||
fail("expected Err for green")
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
test "enum exclude multiple values" {
|
||||
let s = enum_values(["a", "b", "c", "d"]).exclude(["a", "c"])
|
||||
guard s.parse(Json::string("b")) is Ok(_) else { fail("expected Ok") }
|
||||
guard s.parse(Json::string("d")) is Ok(_) else { fail("expected Ok") }
|
||||
guard s.parse(Json::string("a")) is Err(_) else { fail("expected Err") }
|
||||
guard s.parse(Json::string("c")) is Err(_) else { fail("expected Err") }
|
||||
}
|
||||
|
||||
///|
|
||||
test "enum exclude preserves metadata" {
|
||||
let s = enum_values(["x", "y"], invalid_type_error="not enum")
|
||||
.name("MyEnum")
|
||||
.exclude(["y"])
|
||||
@debug.assert_eq(s.name, "MyEnum")
|
||||
@debug.assert_eq(s.invalid_type_error, "not enum")
|
||||
guard s.parse(Json::string("x")) is Ok(_) else { fail("expected Ok") }
|
||||
guard s.parse(Json::string("y")) is Err(_) else { fail("expected Err") }
|
||||
}
|
||||
|
||||
///|
|
||||
test "enum extract keeps only specified values" {
|
||||
let s = enum_values(["red", "green", "blue"]).extract(["red", "blue"])
|
||||
guard s.parse(Json::string("red")) is Ok(_) else { fail("expected Ok") }
|
||||
guard s.parse(Json::string("blue")) is Ok(_) else { fail("expected Ok") }
|
||||
guard s.parse(Json::string("green")) is Err(_) else {
|
||||
fail("expected Err for green")
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
test "enum extract ignores values not in original" {
|
||||
let s = enum_values(["a", "b"]).extract(["a", "c"])
|
||||
guard s.parse(Json::string("a")) is Ok(_) else { fail("expected Ok") }
|
||||
guard s.parse(Json::string("b")) is Err(_) else { fail("expected Err") }
|
||||
}
|
||||
|
||||
///|
|
||||
test "enum exclude all values produces empty enum" {
|
||||
let s = enum_values(["a", "b"]).exclude(["a", "b"])
|
||||
guard s.parse(Json::string("a")) is Err(_) else { fail("expected Err") }
|
||||
guard s.parse(Json::string("b")) is Err(_) else { fail("expected Err") }
|
||||
}
|
||||
|
||||
///|
|
||||
test "enum extract empty list produces empty enum" {
|
||||
let s = enum_values(["a", "b"]).extract([])
|
||||
guard s.parse(Json::string("a")) is Err(_) else { fail("expected Err") }
|
||||
}
|
||||
|
||||
///|
|
||||
test "enum exclude preserves rules" {
|
||||
let s = enum_values(["a", "b"])
|
||||
.refine(
|
||||
fn(json) {
|
||||
match json {
|
||||
String(v) => v.length() > 0
|
||||
_ => false
|
||||
}
|
||||
},
|
||||
"must not be empty",
|
||||
)
|
||||
.exclude(["b"])
|
||||
guard s.parse(Json::string("a")) is Ok(_) else { fail("expected Ok") }
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
///|
|
||||
test "pipe chains two schemas" {
|
||||
let s = string().pipe(string().min(3).max(10))
|
||||
guard s.parse(Json::string("hello")) is Ok(_) else { fail("expected Ok") }
|
||||
}
|
||||
|
||||
///|
|
||||
test "pipe rejects first stage" {
|
||||
let s = number().pipe(string())
|
||||
guard s.parse(Json::string("hello")) is Err(_) else { fail("expected Err") }
|
||||
}
|
||||
|
||||
///|
|
||||
test "pipe rejects second stage" {
|
||||
let s = string().pipe(string().min(3))
|
||||
guard s.parse(Json::string("ab")) is Err(errors) else { fail("expected Err") }
|
||||
@debug.assert_eq(errors.length(), 1)
|
||||
match errors[0].code {
|
||||
IssueCode::TooSmall(_, _, _) => ()
|
||||
_ => fail("expected TooSmall")
|
||||
}
|
||||
}
|
||||
|
||||
///|
|
||||
test "pipe with transform bridges types" {
|
||||
let s = string()
|
||||
.transform(fn(json) {
|
||||
match json {
|
||||
String(s) => Ok(Json::number(s.length().to_double()))
|
||||
_ => Err("expected string")
|
||||
}
|
||||
})
|
||||
.pipe(number().min(3).max(10).int())
|
||||
// "hello".length() = 5, which passes min(3) and max(10)
|
||||
guard s.parse(Json::string("hello")) is Ok(_) else { fail("expected Ok") }
|
||||
// "hi".length() = 2, fails min(3)
|
||||
guard s.parse(Json::string("hi")) is Err(_) else { fail("expected Err") }
|
||||
}
|
||||
|
||||
///|
|
||||
test "pipe reports first stage error before second stage" {
|
||||
// If first stage fails, second stage is never tried
|
||||
let s = number()
|
||||
.refine(
|
||||
fn(json) {
|
||||
match json {
|
||||
Number(v, ..) => v > 0.0
|
||||
_ => false
|
||||
}
|
||||
},
|
||||
"must be positive",
|
||||
)
|
||||
.pipe(string())
|
||||
guard s.parse(Json::number(-1.0)) is Err(_) else { fail("expected Err") }
|
||||
}
|
||||
|
||||
///|
|
||||
test "pipe with chained rules after pipe" {
|
||||
let s = string()
|
||||
.pipe(number())
|
||||
.refine(
|
||||
fn(json) {
|
||||
match json {
|
||||
Number(v, ..) => v > 0.0
|
||||
_ => false
|
||||
}
|
||||
},
|
||||
"must be positive",
|
||||
)
|
||||
guard s.parse(Json::string("42")) is Err(_) else { fail("expected Err") }
|
||||
}
|
||||
|
||||
///|
|
||||
test "pipe error_map works for second stage" {
|
||||
let s = string().pipe(string().min(3))
|
||||
let params = ParseParams::{
|
||||
path: "",
|
||||
error_map: Some(fn(code, _path, _input) {
|
||||
match code {
|
||||
IssueCode::TooSmall(_, _, _) => Some("太短了")
|
||||
_ => None
|
||||
}
|
||||
}),
|
||||
}
|
||||
match s.safe_parse(Json::string("ab"), params) {
|
||||
Err(errors) => @debug.assert_eq(errors[0].message, "太短了")
|
||||
_ => fail("expected Err")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
///|
|
||||
fn tree_schema() -> Schema {
|
||||
recursive(fn() {
|
||||
object({
|
||||
"value": number(),
|
||||
"children": array(recursive(tree_schema)).optional(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
///|
|
||||
test "recursive tree schema parses valid input" {
|
||||
let s = tree_schema()
|
||||
let input = parse_json("{\"value\": 1, \"children\": [{\"value\": 2}]}")
|
||||
guard s.parse(input) is Ok(_) else { fail("expected Ok") }
|
||||
}
|
||||
|
||||
///|
|
||||
test "recursive tree schema rejects invalid input" {
|
||||
let s = tree_schema()
|
||||
let input = parse_json("{\"value\": \"not-a-number\"}")
|
||||
guard s.parse(input) is Err(_) else { fail("expected Err") }
|
||||
}
|
||||
|
||||
///|
|
||||
test "recursive schema with named root" {
|
||||
let s = tree_schema().name("Tree")
|
||||
let input = parse_json("{\"value\": 1}")
|
||||
guard s.parse(input) is Ok(_) else { fail("expected Ok") }
|
||||
}
|
||||
|
||||
///|
|
||||
test "recursive schema self-reference parses deeply nested" {
|
||||
let s = tree_schema()
|
||||
let input = parse_json(
|
||||
"{\"value\": 1, \"children\": [{\"value\": 2, \"children\": [{\"value\": 3}]}]}",
|
||||
)
|
||||
guard s.parse(input) is Ok(_) else { fail("expected Ok") }
|
||||
}
|
||||
|
||||
///|
|
||||
test "recursive schema rejects invalid nested value" {
|
||||
let s = tree_schema()
|
||||
let input = parse_json(
|
||||
"{\"value\": 1, \"children\": [{\"value\": 2, \"children\": [{\"value\": \"bad\"}]}]}",
|
||||
)
|
||||
guard s.parse(input) is Err(_) else { fail("expected Err") }
|
||||
}
|
||||
Loading…
Reference in New Issue