@@ -50,6 +50,48 @@ impl CrdtState {
5050 Ok ( Self { doc, peer_id } )
5151 }
5252
53+ /// Fetch a row's existing `LoroMap` container, or create one if absent.
54+ /// Shared by `upsert` and `set_fields` — both need the same row handle
55+ /// before diverging on prune-vs-preserve semantics.
56+ fn row_container ( & self , collection : & str , row_id : & str ) -> Result < LoroMap > {
57+ let coll = self . doc . get_map ( collection) ;
58+ match coll. get ( row_id) {
59+ Some ( ValueOrContainer :: Container ( loro:: Container :: Map ( m) ) ) => Ok ( m) ,
60+ _ => coll
61+ . insert_container ( row_id, LoroMap :: new ( ) )
62+ . map_err ( |e| CrdtError :: Loro ( e. to_string ( ) ) ) ,
63+ }
64+ }
65+
66+ /// Write `fields` onto `row_container` as scalar LWW inserts, rejecting
67+ /// any key that currently holds a container value. Shared by `upsert`
68+ /// and `set_fields` — both write the same way, only the prune step
69+ /// (upsert-only) differs.
70+ fn write_scalar_fields (
71+ row_container : & LoroMap ,
72+ collection : & str ,
73+ row_id : & str ,
74+ fields : & [ ( & str , LoroValue ) ] ,
75+ ) -> Result < ( ) > {
76+ for ( field, value) in fields {
77+ // A container-valued key can never legitimately appear in the
78+ // incoming scalar projection. Overwriting one would destroy the
79+ // nested container; skipping it would silently discard the
80+ // caller's write. Reject instead of doing either.
81+ if key_is_container ( row_container, field) {
82+ return Err ( CrdtError :: ScalarFieldShadowsContainer {
83+ collection : collection. to_string ( ) ,
84+ row_id : row_id. to_string ( ) ,
85+ field : ( * field) . to_string ( ) ,
86+ } ) ;
87+ }
88+ row_container
89+ . insert ( field, value. clone ( ) )
90+ . map_err ( |e| CrdtError :: Loro ( e. to_string ( ) ) ) ?;
91+ }
92+ Ok ( ( ) )
93+ }
94+
5395 /// Insert or update a row in a collection.
5496 ///
5597 /// This is a REPLACE for scalar fields — every caller passes the
@@ -66,13 +108,7 @@ impl CrdtState {
66108 row_id : & str ,
67109 fields : & [ ( & str , LoroValue ) ] ,
68110 ) -> Result < ( ) > {
69- let coll = self . doc . get_map ( collection) ;
70- let row_container = match coll. get ( row_id) {
71- Some ( ValueOrContainer :: Container ( loro:: Container :: Map ( m) ) ) => m,
72- _ => coll
73- . insert_container ( row_id, LoroMap :: new ( ) )
74- . map_err ( |e| CrdtError :: Loro ( e. to_string ( ) ) ) ?,
75- } ;
111+ let row_container = self . row_container ( collection, row_id) ?;
76112
77113 let incoming_keys: HashSet < & str > = fields. iter ( ) . map ( |( field, _) | * field) . collect ( ) ;
78114
@@ -94,23 +130,21 @@ impl CrdtState {
94130 . map_err ( |e| CrdtError :: Loro ( e. to_string ( ) ) ) ?;
95131 }
96132
97- for ( field, value) in fields {
98- // A container-valued key can never legitimately appear in the
99- // incoming scalar projection. Overwriting one would destroy the
100- // nested container; skipping it would silently discard the
101- // caller's write. Reject instead of doing either.
102- if key_is_container ( & row_container, field) {
103- return Err ( CrdtError :: ScalarFieldShadowsContainer {
104- collection : collection. to_string ( ) ,
105- row_id : row_id. to_string ( ) ,
106- field : ( * field) . to_string ( ) ,
107- } ) ;
108- }
109- row_container
110- . insert ( field, value. clone ( ) )
111- . map_err ( |e| CrdtError :: Loro ( e. to_string ( ) ) ) ?;
112- }
113- Ok ( ( ) )
133+ Self :: write_scalar_fields ( & row_container, collection, row_id, fields)
134+ }
135+
136+ /// Partial-merge write: set exactly the provided scalar `fields` on a row
137+ /// (LWW-per-field), creating the row if absent, leaving every untouched
138+ /// key intact. This is `upsert` WITHOUT the full-projection prune step —
139+ /// the UPDATE-SET semantic for `CrdtOp::DocUpsert { partial: true }`.
140+ pub fn set_fields (
141+ & self ,
142+ collection : & str ,
143+ row_id : & str ,
144+ fields : & [ ( & str , LoroValue ) ] ,
145+ ) -> Result < ( ) > {
146+ let row_container = self . row_container ( collection, row_id) ?;
147+ Self :: write_scalar_fields ( & row_container, collection, row_id, fields)
114148 }
115149
116150 /// Delete a row from a collection.
@@ -315,3 +349,55 @@ impl RowLookup for CrdtState {
315349 self . field_value_exists_live ( collection, field, value, exclude_row_id)
316350 }
317351}
352+
353+ #[ cfg( test) ]
354+ mod tests {
355+ use super :: * ;
356+
357+ const COLL : & str = "c" ;
358+ const ROW : & str = "r" ;
359+
360+ #[ test]
361+ fn set_fields_preserves_untouched_keys_and_upsert_prunes ( ) {
362+ let state = CrdtState :: new ( 0 ) . expect ( "state" ) ;
363+
364+ // Full projection {a:1, b:2}.
365+ state
366+ . upsert (
367+ COLL ,
368+ ROW ,
369+ & [ ( "a" , LoroValue :: I64 ( 1 ) ) , ( "b" , LoroValue :: I64 ( 2 ) ) ] ,
370+ )
371+ . expect ( "upsert" ) ;
372+
373+ // Partial-merge {b:9}: `a` must survive untouched, `b` overwritten.
374+ state
375+ . set_fields ( COLL , ROW , & [ ( "b" , LoroValue :: I64 ( 9 ) ) ] )
376+ . expect ( "set_fields" ) ;
377+ assert_eq ! (
378+ state. read_field( COLL , ROW , "a" ) ,
379+ Some ( LoroValue :: I64 ( 1 ) ) ,
380+ "set_fields must leave the untouched key `a` intact"
381+ ) ;
382+ assert_eq ! (
383+ state. read_field( COLL , ROW , "b" ) ,
384+ Some ( LoroValue :: I64 ( 9 ) ) ,
385+ "set_fields must overwrite `b` to 9"
386+ ) ;
387+
388+ // Full-projection replace {a:5}: absent key `b` must be pruned.
389+ state
390+ . upsert ( COLL , ROW , & [ ( "a" , LoroValue :: I64 ( 5 ) ) ] )
391+ . expect ( "upsert replace" ) ;
392+ assert_eq ! (
393+ state. read_field( COLL , ROW , "a" ) ,
394+ Some ( LoroValue :: I64 ( 5 ) ) ,
395+ "upsert must set `a` to 5"
396+ ) ;
397+ assert_eq ! (
398+ state. read_field( COLL , ROW , "b" ) ,
399+ None ,
400+ "upsert must prune key `b` absent from the projection"
401+ ) ;
402+ }
403+ }
0 commit comments