@@ -3158,3 +3158,274 @@ func importWithIngestOpts(
31583158 _ , _ , err = mi .ImportDocuments ()
31593159 return err
31603160}
3161+
3162+ // TestImportModeUpsertFields tests --mode with --upsertFields a,c (compound key matching).
3163+ // The collection starts with two docs: {a:1234, c:222, x:"original field"} and
3164+ // {a:4567, c:333, x:"original field"}.
3165+ // The import file has five entries:
3166+ // - (a=1234,c=222) appears twice; last write sets b="blah"
3167+ // - (a=4567,c=333) matches the second doc and sets b="yyy"
3168+ // - (a=4567,c=222) has no match and becomes a new doc with b="asdf"
3169+ func TestImportModeUpsertFields (t * testing.T ) {
3170+ testtype .SkipUnlessTestType (t , testtype .IntegrationTestType )
3171+
3172+ const (
3173+ dbName = "mongoimport_modes_upsertfields_test"
3174+ collName = "c"
3175+ )
3176+
3177+ sessionProvider , _ , err := testutil .GetBareSessionProvider ()
3178+ require .NoError (t , err )
3179+ client , err := sessionProvider .GetSession ()
3180+ require .NoError (t , err )
3181+ t .Cleanup (func () {
3182+ _ = client .Database (dbName ).Drop (context .Background ())
3183+ })
3184+
3185+ coll := client .Database (dbName ).Collection (collName )
3186+ ns := & options.Namespace {DB : dbName , Collection : collName }
3187+ dir := t .TempDir ()
3188+
3189+ importFile := writeJSONLinesFile (t , dir , "upsert2.json" , []map [string ]any {
3190+ {"a" : 1234 , "b" : 4567 , "c" : 222 },
3191+ {"a" : 4567 , "b" : "yyy" , "c" : 333 },
3192+ {"a" : 1234 , "b" : "blah" , "c" : 222 },
3193+ {"a" : "xxx" , "b" : "test" , "c" : - 1 },
3194+ {"a" : 4567 , "b" : "asdf" , "c" : 222 },
3195+ })
3196+
3197+ t .Run ("mode=wrong returns error" , func (t * testing.T ) {
3198+ setupUpsertFieldsDocs (t , coll )
3199+ err := runImportOpts (t , ns , importFile , IngestOptions {Mode : "wrong" , UpsertFields : "a,c" })
3200+ require .ErrorContains (t , err , "invalid --mode argument" )
3201+ })
3202+
3203+ t .Run ("mode=insert returns error" , func (t * testing.T ) {
3204+ setupUpsertFieldsDocs (t , coll )
3205+ err := runImportOpts (
3206+ t ,
3207+ ns ,
3208+ importFile ,
3209+ IngestOptions {Mode : modeInsert , UpsertFields : "a,c" },
3210+ )
3211+ require .ErrorContains (t , err , "cannot use --upsertFields with --mode=insert" )
3212+ })
3213+
3214+ // Default mode, mode=upsert, and deprecated --upsert all replace matched docs.
3215+ for _ , tc := range []struct {
3216+ name string
3217+ opts IngestOptions
3218+ }{
3219+ {"default mode" , IngestOptions {UpsertFields : "a,c" }},
3220+ {"deprecated --upsert" , IngestOptions {Upsert : true , UpsertFields : "a,c" }},
3221+ {"mode=upsert" , IngestOptions {Mode : modeUpsert , UpsertFields : "a,c" }},
3222+ } {
3223+ t .Run (tc .name + " replaces matched docs" , func (t * testing.T ) {
3224+ setupUpsertFieldsDocs (t , coll )
3225+ require .NoError (t , runImportOpts (t , ns , importFile , tc .opts ))
3226+
3227+ var doc map [string ]any
3228+ require .NoError (t , coll .FindOne (t .Context (), bson.D {{"b" , "blah" }}).Decode (& doc ))
3229+ assert .Nil (t , doc ["x" ], "upsert replaces doc; original x field gone" )
3230+
3231+ doc = map [string ]any {}
3232+ require .NoError (t , coll .FindOne (t .Context (), bson.D {{"b" , "yyy" }}).Decode (& doc ))
3233+ assert .Nil (t , doc ["x" ], "upsert replaces doc; original x field gone" )
3234+
3235+ doc = map [string ]any {}
3236+ require .NoError (t , coll .FindOne (t .Context (), bson.D {{"b" , "asdf" }}).Decode (& doc ))
3237+ assert .Nil (t , doc ["x" ], "newly inserted doc has no x field" )
3238+ })
3239+ }
3240+
3241+ t .Run ("mode=merge updates matched docs and preserves unset fields" , func (t * testing.T ) {
3242+ setupUpsertFieldsDocs (t , coll )
3243+ require .NoError (
3244+ t ,
3245+ runImportOpts (t , ns , importFile , IngestOptions {Mode : modeMerge , UpsertFields : "a,c" }),
3246+ )
3247+
3248+ var doc map [string ]any
3249+ require .NoError (t , coll .FindOne (t .Context (), bson.D {{"b" , "blah" }}).Decode (& doc ))
3250+ assert .Equal (t , "original field" , doc ["x" ], "merge preserves x on matched doc" )
3251+
3252+ doc = map [string ]any {}
3253+ require .NoError (t , coll .FindOne (t .Context (), bson.D {{"b" , "yyy" }}).Decode (& doc ))
3254+ assert .Equal (t , "original field" , doc ["x" ], "merge preserves x on matched doc" )
3255+
3256+ doc = map [string ]any {}
3257+ require .NoError (t , coll .FindOne (t .Context (), bson.D {{"b" , "asdf" }}).Decode (& doc ))
3258+ assert .Nil (t , doc ["x" ], "newly inserted doc has no x field" )
3259+ })
3260+ }
3261+
3262+ // TestImportModeByID tests --mode without --upsertFields (matches on _id).
3263+ // The collection starts with {_id:"one", a:"original value", x:"original field"} and
3264+ // {_id:"two", a:"original value 2", x:"original field"}.
3265+ // The import file has five entries; _id="one" appears four times and last write wins,
3266+ // leaving it as {a:"unicorns", b:"zebras"}. _id="two" has one entry: {a:"xxx", b:"yyy"}.
3267+ func TestImportModeByID (t * testing.T ) {
3268+ testtype .SkipUnlessTestType (t , testtype .IntegrationTestType )
3269+
3270+ const (
3271+ dbName = "mongoimport_modes_byid_test"
3272+ collName = "c"
3273+ )
3274+
3275+ sessionProvider , _ , err := testutil .GetBareSessionProvider ()
3276+ require .NoError (t , err )
3277+ client , err := sessionProvider .GetSession ()
3278+ require .NoError (t , err )
3279+ t .Cleanup (func () {
3280+ _ = client .Database (dbName ).Drop (context .Background ())
3281+ })
3282+
3283+ coll := client .Database (dbName ).Collection (collName )
3284+ ns := & options.Namespace {DB : dbName , Collection : collName }
3285+ dir := t .TempDir ()
3286+
3287+ importFile := writeJSONLinesFile (t , dir , "upsert1.json" , []map [string ]any {
3288+ {"_id" : "one" , "a" : 1234 , "b" : 4567 },
3289+ {"_id" : "two" , "a" : "xxx" , "b" : "yyy" },
3290+ {"_id" : "one" , "a" : "foo" , "b" : "blah" },
3291+ {"_id" : "one" , "a" : "test" , "b" : "test" },
3292+ {"_id" : "one" , "a" : "unicorns" , "b" : "zebras" },
3293+ })
3294+
3295+ t .Run ("mode=wrong returns error" , func (t * testing.T ) {
3296+ setupByIDDocs (t , coll )
3297+ err := runImportOpts (t , ns , importFile , IngestOptions {Mode : "wrong" })
3298+ require .ErrorContains (t , err , "invalid --mode argument" )
3299+ })
3300+
3301+ // mode=insert and the default mode both skip all duplicates.
3302+ for _ , tc := range []struct {
3303+ name string
3304+ opts IngestOptions
3305+ }{
3306+ {"mode=insert" , IngestOptions {Mode : modeInsert }},
3307+ {"default mode" , IngestOptions {}},
3308+ } {
3309+ t .Run (tc .name + " skips duplicates" , func (t * testing.T ) {
3310+ setupByIDDocs (t , coll )
3311+ require .NoError (t , runImportOpts (t , ns , importFile , tc .opts ))
3312+
3313+ n , err := coll .CountDocuments (t .Context (), bson.D {})
3314+ require .NoError (t , err )
3315+ assert .EqualValues (t , 2 , n , "all entries were duplicates; count unchanged" )
3316+
3317+ var doc map [string ]any
3318+ require .NoError (t , coll .FindOne (t .Context (), bson.D {{"_id" , "one" }}).Decode (& doc ))
3319+ assert .Equal (t , "original value" , doc ["a" ], "_id=one unchanged" )
3320+ assert .Equal (t , "original field" , doc ["x" ], "_id=one unchanged" )
3321+ })
3322+ }
3323+
3324+ // Deprecated --upsert and explicit mode=upsert both replace docs by _id.
3325+ for _ , tc := range []struct {
3326+ name string
3327+ opts IngestOptions
3328+ }{
3329+ {"deprecated --upsert" , IngestOptions {Upsert : true }},
3330+ {"mode=upsert" , IngestOptions {Mode : modeUpsert }},
3331+ } {
3332+ t .Run (tc .name + " replaces docs" , func (t * testing.T ) {
3333+ setupByIDDocs (t , coll )
3334+ require .NoError (t , runImportOpts (t , ns , importFile , tc .opts ))
3335+
3336+ var doc map [string ]any
3337+ require .NoError (t , coll .FindOne (t .Context (), bson.D {{"_id" , "one" }}).Decode (& doc ))
3338+ assert .Equal (t , "unicorns" , doc ["a" ], "last write wins" )
3339+ assert .Equal (t , "zebras" , doc ["b" ], "last write wins" )
3340+ assert .Nil (t , doc ["x" ], "upsert replaces doc; original x field gone" )
3341+
3342+ doc = map [string ]any {}
3343+ require .NoError (t , coll .FindOne (t .Context (), bson.D {{"_id" , "two" }}).Decode (& doc ))
3344+ assert .Equal (t , "xxx" , doc ["a" ])
3345+ assert .Equal (t , "yyy" , doc ["b" ])
3346+ assert .Nil (t , doc ["x" ], "upsert replaces doc; original x field gone" )
3347+ })
3348+ }
3349+
3350+ t .Run ("mode=merge updates docs and preserves unset fields" , func (t * testing.T ) {
3351+ setupByIDDocs (t , coll )
3352+ require .NoError (t , runImportOpts (t , ns , importFile , IngestOptions {Mode : modeMerge }))
3353+
3354+ var doc map [string ]any
3355+ require .NoError (t , coll .FindOne (t .Context (), bson.D {{"_id" , "one" }}).Decode (& doc ))
3356+ assert .Equal (t , "unicorns" , doc ["a" ], "last write wins" )
3357+ assert .Equal (t , "zebras" , doc ["b" ], "last write wins" )
3358+ assert .Equal (t , "original field" , doc ["x" ], "merge preserves x" )
3359+
3360+ doc = map [string ]any {}
3361+ require .NoError (t , coll .FindOne (t .Context (), bson.D {{"_id" , "two" }}).Decode (& doc ))
3362+ assert .Equal (t , "xxx" , doc ["a" ])
3363+ assert .Equal (t , "yyy" , doc ["b" ])
3364+ assert .Equal (t , "original field" , doc ["x" ], "merge preserves x" )
3365+ })
3366+ }
3367+
3368+ func setupUpsertFieldsDocs (t * testing.T , coll * mongo.Collection ) {
3369+ t .Helper ()
3370+ require .NoError (t , coll .Drop (t .Context ()))
3371+ _ , err := coll .InsertMany (
3372+ t .Context (),
3373+ []any {
3374+ bson.D {{"a" , 1234 }, {"b" , "000000" }, {"c" , 222 }, {"x" , "original field" }},
3375+ bson.D {{"a" , 4567 }, {"b" , "111111" }, {"c" , 333 }, {"x" , "original field" }},
3376+ },
3377+ )
3378+ require .NoError (t , err )
3379+ }
3380+
3381+ func setupByIDDocs (t * testing.T , coll * mongo.Collection ) {
3382+ t .Helper ()
3383+ require .NoError (t , coll .Drop (t .Context ()))
3384+ _ , err := coll .InsertMany (
3385+ t .Context (),
3386+ []any {
3387+ bson.D {{"_id" , "one" }, {"a" , "original value" }, {"x" , "original field" }},
3388+ bson.D {{"_id" , "two" }, {"a" , "original value 2" }, {"x" , "original field" }},
3389+ },
3390+ )
3391+ require .NoError (t , err )
3392+ }
3393+
3394+ // runImportOpts is like importWithIngestOpts but also returns errors from New,
3395+ // allowing callers to test for invalid-options errors.
3396+ func runImportOpts (
3397+ t * testing.T ,
3398+ ns * options.Namespace ,
3399+ filePath string ,
3400+ ingestOpts IngestOptions ,
3401+ ) error {
3402+ t .Helper ()
3403+ toolOptions , err := testutil .GetToolOptions ()
3404+ require .NoError (t , err )
3405+ toolOptions .Namespace = ns
3406+ mi , err := New (Options {
3407+ ToolOptions : toolOptions ,
3408+ InputOptions : & InputOptions {File : filePath , ParseGrace : "stop" },
3409+ IngestOptions : & ingestOpts ,
3410+ })
3411+ if err != nil {
3412+ return err
3413+ }
3414+ defer mi .Close ()
3415+ _ , _ , err = mi .ImportDocuments ()
3416+ return err
3417+ }
3418+
3419+ // writeJSONLinesFile marshals each doc in docs as a JSON object and writes them
3420+ // as newline-separated lines to a file in dir named name. Returns the file path.
3421+ func writeJSONLinesFile (t * testing.T , dir , name string , docs []map [string ]any ) string {
3422+ t .Helper ()
3423+ var buf bytes.Buffer
3424+ for _ , doc := range docs {
3425+ b , err := json .Marshal (doc )
3426+ require .NoError (t , err )
3427+ buf .Write (b )
3428+ buf .WriteByte ('\n' )
3429+ }
3430+ return writeTestFile (t , dir , name , buf .String ())
3431+ }
0 commit comments