@@ -12,7 +12,8 @@ use schema_forge_backend::conditional::{ConditionalMutationError, EntityRevision
1212use schema_forge_backend:: entity:: Entity ;
1313use schema_forge_core:: query:: { validate_filter, FieldPath , Filter , SortOrder } ;
1414use schema_forge_core:: types:: {
15- Cardinality , DynamicValue , EntityId , FieldType , SchemaDefinition , SchemaName ,
15+ Cardinality , ConstraintViolation , DynamicValue , EntityId , FieldType , SchemaDefinition ,
16+ SchemaName ,
1617} ;
1718use serde:: { Deserialize , Serialize } ;
1819use tokio:: sync:: oneshot;
@@ -817,14 +818,50 @@ pub fn json_to_entity_fields_with_mode(
817818/// this to a 422), never truncated or silently accepted.
818819fn enforce_bytes_max_size ( bytes : & [ u8 ] , max_size : Option < usize > ) -> Result < ( ) , String > {
819820 match max_size {
820- Some ( max) if bytes. len ( ) > max => Err ( format ! (
821- "bytes value of {} bytes exceeds the field's max_size of {max} bytes" ,
822- bytes. len( )
823- ) ) ,
821+ Some ( max) if bytes. len ( ) > max => Err ( ConstraintViolation :: BytesTooLarge {
822+ len : bytes. len ( ) ,
823+ max,
824+ }
825+ . to_string ( ) ) ,
824826 _ => Ok ( ( ) ) ,
825827 }
826828}
827829
830+ /// Check every value in a write against the constraints declared on its
831+ /// field's type, collecting all violations into a single 422.
832+ ///
833+ /// Runs at the last seam before the backend, which is what makes it
834+ /// complete: by this point the field map holds client JSON, `@default` and
835+ /// `@compute` rule output, server-injected columns, and anything a
836+ /// `before_*` hook substituted. Checking earlier would leave the later
837+ /// sources unguarded, and an unguarded violation reaches the database, whose
838+ /// refusal arrives as an untyped driver error and surfaces as a 502 — a
839+ /// retryable status for a request that can never succeed. See #133.
840+ ///
841+ /// Values whose names are not in the schema (`_tenant` and friends) carry no
842+ /// declared constraints and are skipped.
843+ fn check_field_constraints (
844+ schema : & SchemaDefinition ,
845+ fields : & BTreeMap < String , DynamicValue > ,
846+ ) -> Result < ( ) , ForgeError > {
847+ let details: Vec < String > = fields
848+ . iter ( )
849+ . filter_map ( |( name, value) | {
850+ let field_def = schema. field ( name) ?;
851+ field_def
852+ . field_type
853+ . check_value ( value)
854+ . err ( )
855+ . map ( |violation| format ! ( "field '{name}': {violation}" ) )
856+ } )
857+ . collect ( ) ;
858+ if details. is_empty ( ) {
859+ Ok ( ( ) )
860+ } else {
861+ Err ( ForgeError :: ValidationFailed { details } )
862+ }
863+ }
864+
828865fn convert_json_with_type_hint (
829866 value : & serde_json:: Value ,
830867 field_type : & FieldType ,
@@ -2272,6 +2309,7 @@ pub async fn create_entity(
22722309 claims. as_ref ( ) ,
22732310 FieldFilterDirection :: Write ,
22742311 ) ;
2312+ check_field_constraints ( & schema_def, & entity. fields ) ?;
22752313
22762314 // Create entity via actor (supervised backend call)
22772315 let ( tx, rx) = oneshot:: channel ( ) ;
@@ -3014,6 +3052,7 @@ pub async fn update_entity(
30143052 claims. as_ref ( ) ,
30153053 FieldFilterDirection :: Write ,
30163054 ) ;
3055+ check_field_constraints ( & schema_def, & entity. fields ) ?;
30173056
30183057 let ( mut updated, revision) = persist_entity_update ( & forge, entity, expected) . await ?;
30193058
@@ -3319,6 +3358,7 @@ pub async fn patch_entity(
33193358 claims. as_ref ( ) ,
33203359 FieldFilterDirection :: Write ,
33213360 ) ;
3361+ check_field_constraints ( & schema_def, & entity. fields ) ?;
33223362 persist_entity_update ( & forge, entity, expected) . await ?
33233363 } ;
33243364
@@ -3703,6 +3743,121 @@ mod tests {
37033743 . unwrap ( )
37043744 }
37053745
3746+ // ---- check_field_constraints (#133) ----
3747+
3748+ /// The neutral repro from #133: every constraint the DSL can express,
3749+ /// on one schema.
3750+ fn make_constrained_schema ( ) -> SchemaDefinition {
3751+ use schema_forge_core:: types:: { EnumVariants , IntegerConstraints } ;
3752+ SchemaDefinition :: new (
3753+ SchemaId :: new ( ) ,
3754+ SchemaName :: new ( "Widget" ) . unwrap ( ) ,
3755+ vec ! [
3756+ FieldDefinition :: new(
3757+ FieldName :: new( "name" ) . unwrap( ) ,
3758+ FieldType :: Text ( TextConstraints :: with_max_length( 10 ) ) ,
3759+ ) ,
3760+ FieldDefinition :: new(
3761+ FieldName :: new( "size" ) . unwrap( ) ,
3762+ FieldType :: Integer ( IntegerConstraints :: with_range( 1 , 5 ) . unwrap( ) ) ,
3763+ ) ,
3764+ FieldDefinition :: new(
3765+ FieldName :: new( "kind" ) . unwrap( ) ,
3766+ FieldType :: Enum (
3767+ EnumVariants :: new( vec![ "alpha" . into( ) , "beta" . into( ) ] ) . unwrap( ) ,
3768+ ) ,
3769+ ) ,
3770+ ] ,
3771+ vec ! [ ] ,
3772+ )
3773+ . unwrap ( )
3774+ }
3775+
3776+ fn constraint_errors ( fields : & [ ( & str , DynamicValue ) ] ) -> Vec < String > {
3777+ let map: BTreeMap < String , DynamicValue > = fields
3778+ . iter ( )
3779+ . map ( |( k, v) | ( ( * k) . to_string ( ) , v. clone ( ) ) )
3780+ . collect ( ) ;
3781+ match check_field_constraints ( & make_constrained_schema ( ) , & map) {
3782+ Ok ( ( ) ) => Vec :: new ( ) ,
3783+ Err ( ForgeError :: ValidationFailed { details } ) => details,
3784+ Err ( other) => panic ! ( "expected ValidationFailed, got {other:?}" ) ,
3785+ }
3786+ }
3787+
3788+ #[ test]
3789+ fn check_field_constraints_accepts_a_conforming_write ( ) {
3790+ assert ! ( constraint_errors( & [
3791+ ( "name" , DynamicValue :: Text ( "ok" . into( ) ) ) ,
3792+ ( "size" , DynamicValue :: Integer ( 3 ) ) ,
3793+ ( "kind" , DynamicValue :: Enum ( "alpha" . into( ) ) ) ,
3794+ ] )
3795+ . is_empty( ) ) ;
3796+ }
3797+
3798+ #[ test]
3799+ fn check_field_constraints_rejects_an_unknown_enum_variant ( ) {
3800+ let errors = constraint_errors ( & [ ( "kind" , DynamicValue :: Enum ( "gamma" . into ( ) ) ) ] ) ;
3801+ assert_eq ! ( errors. len( ) , 1 ) ;
3802+ assert ! (
3803+ errors[ 0 ] . contains( "field 'kind'" )
3804+ && errors[ 0 ] . contains( "gamma" )
3805+ && errors[ 0 ] . contains( "alpha, beta" ) ,
3806+ "the 422 must name the field and the allowed variants, got: {}" ,
3807+ errors[ 0 ]
3808+ ) ;
3809+ }
3810+
3811+ #[ test]
3812+ fn check_field_constraints_rejects_an_over_length_text ( ) {
3813+ let errors = constraint_errors ( & [ (
3814+ "name" ,
3815+ DynamicValue :: Text ( "this is far too long" . into ( ) ) ,
3816+ ) ] ) ;
3817+ assert_eq ! ( errors. len( ) , 1 ) ;
3818+ assert ! ( errors[ 0 ] . contains( "field 'name'" ) , "got: {}" , errors[ 0 ] ) ;
3819+ }
3820+
3821+ #[ test]
3822+ fn check_field_constraints_rejects_an_out_of_range_integer ( ) {
3823+ assert_eq ! ( constraint_errors( & [ ( "size" , DynamicValue :: Integer ( 0 ) ) ] ) . len( ) , 1 ) ;
3824+ assert_eq ! ( constraint_errors( & [ ( "size" , DynamicValue :: Integer ( 9 ) ) ] ) . len( ) , 1 ) ;
3825+ }
3826+
3827+ #[ test]
3828+ fn check_field_constraints_reports_every_violation_at_once ( ) {
3829+ // One round trip should tell the caller everything that is wrong,
3830+ // the way the existing type and required-field errors already do.
3831+ let errors = constraint_errors ( & [
3832+ ( "name" , DynamicValue :: Text ( "this is far too long" . into ( ) ) ) ,
3833+ ( "size" , DynamicValue :: Integer ( 0 ) ) ,
3834+ ( "kind" , DynamicValue :: Enum ( "gamma" . into ( ) ) ) ,
3835+ ] ) ;
3836+ assert_eq ! ( errors. len( ) , 3 , "got: {errors:?}" ) ;
3837+ }
3838+
3839+ #[ test]
3840+ fn check_field_constraints_skips_fields_not_in_the_schema ( ) {
3841+ // Server-injected columns like `_tenant` carry no declared
3842+ // constraints and must pass straight through.
3843+ assert ! ( constraint_errors( & [
3844+ ( "_tenant" , DynamicValue :: Text ( "org_0123456789" . repeat( 10 ) ) ) ,
3845+ ] )
3846+ . is_empty( ) ) ;
3847+ }
3848+
3849+ #[ test]
3850+ fn check_field_constraints_ignores_nulls ( ) {
3851+ // Nullability is the `required` modifier's job, enforced in
3852+ // `json_to_entity_fields_with_mode`.
3853+ assert ! ( constraint_errors( & [
3854+ ( "kind" , DynamicValue :: Null ) ,
3855+ ( "size" , DynamicValue :: Null ) ,
3856+ ( "name" , DynamicValue :: Null ) ,
3857+ ] )
3858+ . is_empty( ) ) ;
3859+ }
3860+
37063861 #[ test]
37073862 fn json_to_entity_fields_basic ( ) {
37083863 let schema = make_test_schema ( ) ;
0 commit comments