From f228a8bb3331309aaa9ee3d52c23f93008e4f361 Mon Sep 17 00:00:00 2001 From: sepeterson <10458078+sepeterson@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:19:08 -0500 Subject: [PATCH 1/3] add a function to map realm taxon data to an API-shaped POJO --- src/realmModels/Taxon.js | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/realmModels/Taxon.js b/src/realmModels/Taxon.js index 3676dfb54..3d81c82e4 100644 --- a/src/realmModels/Taxon.js +++ b/src/realmModels/Taxon.js @@ -160,6 +160,43 @@ class Taxon extends Realm.Object { }; } + /** + * Inverse of mapApiToRealm: maps a live Realm Taxon into the plain, + * snake_case ApiTaxon shape the API and most display/search code expect. + * Live Realm objects expose camelCase mapTo accessors (preferredCommonName, + * defaultPhoto); we read those (falling back to the snake_case keys so a + * plain object passes through unchanged) and return a detached POJO. Use + * this whenever a live Taxon needs to leave the data layer (React/Zustand + * state, async boundaries, child components) so we aren't passing around an + * object Realm can invalidate underneath us. + * + * @param {object} taxon - a live Realm Taxon (or an already-plain object) + * @returns {object} plain ApiTaxon-shaped object + */ + static mapRealmToApi( taxon ) { + if ( !taxon ) return taxon; + const photo = taxon.defaultPhoto || taxon.default_photo; + return { + id: taxon.id, + name: taxon.name, + rank: taxon.rank, + rank_level: taxon.rank_level, + iconic_taxon_name: taxon.iconic_taxon_name, + ancestor_ids: taxon.ancestor_ids + ? Array.from( taxon.ancestor_ids ) + : undefined, + preferred_common_name: taxon.preferredCommonName ?? taxon.preferred_common_name, + default_photo: photo?.url + ? { + id: photo.id, + url: photo.url, + attribution: photo.attribution, + license_code: photo.license_code ?? photo.licenseCode, + } + : undefined, + }; + } + static uri = item => ( item && item.default_photo ) && { uri: item.default_photo.url }; static schema = { From 0baae9872a73bc81c81315ea8e32223cad45b640 Mon Sep 17 00:00:00 2001 From: sepeterson <10458078+sepeterson@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:44:56 -0500 Subject: [PATCH 2/3] unit test --- tests/unit/models/Taxon.test.js | 108 ++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 tests/unit/models/Taxon.test.js diff --git a/tests/unit/models/Taxon.test.js b/tests/unit/models/Taxon.test.js new file mode 100644 index 000000000..1a3a29581 --- /dev/null +++ b/tests/unit/models/Taxon.test.js @@ -0,0 +1,108 @@ +import Taxon from "realmModels/Taxon"; +import safeRealmWrite from "sharedHelpers/safeRealmWrite"; + +describe( "Taxon", ( ) => { + describe( "mapRealmToApi", ( ) => { + it( "returns the value unchanged when given null or undefined", ( ) => { + expect( Taxon.mapRealmToApi( null ) ).toBeNull( ); + expect( Taxon.mapRealmToApi( undefined ) ).toBeUndefined( ); + } ); + + describe( "with an already-plain snake_case object", ( ) => { + const apiTaxon = { + id: 745, + name: "Silphium perfoliatum", + rank: "species", + rank_level: 10, + iconic_taxon_name: "Plantae", + ancestor_ids: [1, 2, 3], + preferred_common_name: "Cup Plant", + default_photo: { + id: 7, + url: "https://example.com/photo.jpg", + attribution: "(c) someone", + license_code: "cc-by", + }, + }; + + it( "passes the snake_case fields through unchanged", ( ) => { + const result = Taxon.mapRealmToApi( apiTaxon ); + expect( result.id ).toBe( 745 ); + expect( result.name ).toBe( "Silphium perfoliatum" ); + expect( result.rank ).toBe( "species" ); + expect( result.rank_level ).toBe( 10 ); + expect( result.iconic_taxon_name ).toBe( "Plantae" ); + expect( result.preferred_common_name ).toBe( "Cup Plant" ); + } ); + + it( "maps the default_photo fields", ( ) => { + const result = Taxon.mapRealmToApi( apiTaxon ); + expect( result.default_photo ).toEqual( { + id: 7, + url: "https://example.com/photo.jpg", + attribution: "(c) someone", + license_code: "cc-by", + } ); + } ); + + it( "returns a detached array copy of ancestor_ids", ( ) => { + const result = Taxon.mapRealmToApi( apiTaxon ); + expect( Array.isArray( result.ancestor_ids ) ).toBe( true ); + expect( result.ancestor_ids ).toEqual( [1, 2, 3] ); + // a copy, not the same reference we passed in + expect( result.ancestor_ids ).not.toBe( apiTaxon.ancestor_ids ); + } ); + } ); + + it( "leaves default_photo undefined when the photo has no url", ( ) => { + const result = Taxon.mapRealmToApi( { + id: 1, + name: "Life", + default_photo: { id: 9, attribution: "(c) someone" }, + } ); + expect( result.default_photo ).toBeUndefined( ); + } ); + + describe( "with a live Realm Taxon", ( ) => { + let realmTaxon; + + beforeEach( ( ) => { + safeRealmWrite( global.realm, ( ) => { + realmTaxon = global.realm.create( "Taxon", { + id: 123, + name: "Danaus plexippus", + rank: "species", + rank_level: 10, + iconic_taxon_name: "Insecta", + ancestor_ids: [48460, 1, 47120], + // exercise the camelCase mapTo accessors + preferred_common_name: "Monarch", + default_photo: { + id: 555, + url: "https://example.com/monarch.jpg", + attribution: "(c) lepidopterist", + license_code: "cc-by-nc", + }, + } ); + }, "create Taxon for mapRealmToApi test" ); + } ); + + afterEach( ( ) => { + safeRealmWrite( global.realm, ( ) => { + global.realm.delete( realmTaxon ); + }, "delete Taxon for mapRealmToApi test" ); + } ); + + it( "returns a detached plain object, not a live Realm object", ( ) => { + const result = Taxon.mapRealmToApi( realmTaxon ); + // a plain object literal, not a live Realm.Object the database can invalidate + expect( Object.getPrototypeOf( result ) ).toBe( Object.prototype ); + expect( result.id ).toBe( 123 ); + expect( result.name ).toBe( "Danaus plexippus" ); + expect( result.rank ).toBe( "species" ); + expect( result.rank_level ).toBe( 10 ); + expect( result.iconic_taxon_name ).toBe( "Insecta" ); + } ); + } ); + } ); +} ); From a03794a3e8e0f5034adb49f37d2a42ea764d6029 Mon Sep 17 00:00:00 2001 From: sepeterson <10458078+sepeterson@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:48:06 -0500 Subject: [PATCH 3/3] rename to mapRealmToPojo --- src/realmModels/Taxon.js | 2 +- tests/unit/models/Taxon.test.js | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/realmModels/Taxon.js b/src/realmModels/Taxon.js index 3d81c82e4..50535c75e 100644 --- a/src/realmModels/Taxon.js +++ b/src/realmModels/Taxon.js @@ -173,7 +173,7 @@ class Taxon extends Realm.Object { * @param {object} taxon - a live Realm Taxon (or an already-plain object) * @returns {object} plain ApiTaxon-shaped object */ - static mapRealmToApi( taxon ) { + static mapRealmToPojo( taxon ) { if ( !taxon ) return taxon; const photo = taxon.defaultPhoto || taxon.default_photo; return { diff --git a/tests/unit/models/Taxon.test.js b/tests/unit/models/Taxon.test.js index 1a3a29581..8b3572016 100644 --- a/tests/unit/models/Taxon.test.js +++ b/tests/unit/models/Taxon.test.js @@ -2,10 +2,10 @@ import Taxon from "realmModels/Taxon"; import safeRealmWrite from "sharedHelpers/safeRealmWrite"; describe( "Taxon", ( ) => { - describe( "mapRealmToApi", ( ) => { + describe( "mapRealmToPojo", ( ) => { it( "returns the value unchanged when given null or undefined", ( ) => { - expect( Taxon.mapRealmToApi( null ) ).toBeNull( ); - expect( Taxon.mapRealmToApi( undefined ) ).toBeUndefined( ); + expect( Taxon.mapRealmToPojo( null ) ).toBeNull( ); + expect( Taxon.mapRealmToPojo( undefined ) ).toBeUndefined( ); } ); describe( "with an already-plain snake_case object", ( ) => { @@ -26,7 +26,7 @@ describe( "Taxon", ( ) => { }; it( "passes the snake_case fields through unchanged", ( ) => { - const result = Taxon.mapRealmToApi( apiTaxon ); + const result = Taxon.mapRealmToPojo( apiTaxon ); expect( result.id ).toBe( 745 ); expect( result.name ).toBe( "Silphium perfoliatum" ); expect( result.rank ).toBe( "species" ); @@ -36,7 +36,7 @@ describe( "Taxon", ( ) => { } ); it( "maps the default_photo fields", ( ) => { - const result = Taxon.mapRealmToApi( apiTaxon ); + const result = Taxon.mapRealmToPojo( apiTaxon ); expect( result.default_photo ).toEqual( { id: 7, url: "https://example.com/photo.jpg", @@ -46,7 +46,7 @@ describe( "Taxon", ( ) => { } ); it( "returns a detached array copy of ancestor_ids", ( ) => { - const result = Taxon.mapRealmToApi( apiTaxon ); + const result = Taxon.mapRealmToPojo( apiTaxon ); expect( Array.isArray( result.ancestor_ids ) ).toBe( true ); expect( result.ancestor_ids ).toEqual( [1, 2, 3] ); // a copy, not the same reference we passed in @@ -55,7 +55,7 @@ describe( "Taxon", ( ) => { } ); it( "leaves default_photo undefined when the photo has no url", ( ) => { - const result = Taxon.mapRealmToApi( { + const result = Taxon.mapRealmToPojo( { id: 1, name: "Life", default_photo: { id: 9, attribution: "(c) someone" }, @@ -84,17 +84,17 @@ describe( "Taxon", ( ) => { license_code: "cc-by-nc", }, } ); - }, "create Taxon for mapRealmToApi test" ); + }, "create Taxon for mapRealmToPojo test" ); } ); afterEach( ( ) => { safeRealmWrite( global.realm, ( ) => { global.realm.delete( realmTaxon ); - }, "delete Taxon for mapRealmToApi test" ); + }, "delete Taxon for mapRealmToPojo test" ); } ); it( "returns a detached plain object, not a live Realm object", ( ) => { - const result = Taxon.mapRealmToApi( realmTaxon ); + const result = Taxon.mapRealmToPojo( realmTaxon ); // a plain object literal, not a live Realm.Object the database can invalidate expect( Object.getPrototypeOf( result ) ).toBe( Object.prototype ); expect( result.id ).toBe( 123 );