LP1889694 Staff catalog record summary API
authorBill Erickson <berickxx@gmail.com>
Fri, 31 Jul 2020 14:54:28 +0000 (10:54 -0400)
committerGalen Charlton <gmc@equinoxinitiative.org>
Thu, 13 Aug 2020 15:16:39 +0000 (11:16 -0400)
Replaces a number of result page and record detail page API calls with a
bespoke API specifically created to return the data required for display
bib and metabib record summary information in the catalog.

Specifically, a single streaming API this replaces the following:

* fleshed record retrieval
** including record display fields and attributes processing.
* copy count retrieval
* hold count retrieval

The end result is 22 API calls per results page replaced with 2.

Signed-off-by: Bill Erickson <berickxx@gmail.com>
Signed-off-by: Galen Charlton <gmc@equinoxinitiative.org>
12 files changed:
Open-ILS/src/eg2/src/app/share/catalog/bib-record.service.ts
Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts
Open-ILS/src/eg2/src/app/staff/cat/vandelay/queued-record-matches.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse/results.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.html
Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.html
Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html
Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.ts
Open-ILS/src/perlmods/lib/OpenILS/Application/Search/Biblio.pm

index d29c4bd..7510fe0 100644 (file)
@@ -33,7 +33,7 @@ export class BibRecordSummary {
     net: NetService;
     displayHighlights: {[name: string]: string | string[]} = {};
 
-    constructor(record: IdlObject, orgId: number, orgDepth: number) {
+    constructor(record: IdlObject, orgId: number, orgDepth?: number) {
         this.id = Number(record.id());
         this.record = record;
         this.orgId = orgId;
@@ -44,63 +44,6 @@ export class BibRecordSummary {
         this.metabibRecords = [];
     }
 
-    ingest() {
-        this.compileDisplayFields();
-        this.compileRecordAttrs();
-
-        // Normalize some data for JS consistency
-        this.record.creator(Number(this.record.creator()));
-        this.record.editor(Number(this.record.editor()));
-    }
-
-    compileDisplayFields() {
-        this.record.flat_display_entries().forEach(entry => {
-            if (entry.multi() === 't') {
-                if (this.display[entry.name()]) {
-                    this.display[entry.name()].push(entry.value());
-                } else {
-                    this.display[entry.name()] = [entry.value()];
-                }
-            } else {
-                this.display[entry.name()] = entry.value();
-            }
-        });
-    }
-
-    compileRecordAttrs() {
-        // Any attr can be multi-valued.
-        this.record.mattrs().forEach(attr => {
-            if (this.attributes[attr.attr()]) {
-                // Avoid dupes
-                if (this.attributes[attr.attr()].indexOf(attr.value()) < 0) {
-                    this.attributes[attr.attr()].push(attr.value());
-                }
-            } else {
-                this.attributes[attr.attr()] = [attr.value()];
-            }
-        });
-    }
-
-    // Get -> Set -> Return bib hold count
-    getHoldCount(): Promise<number> {
-
-        if (Number.isInteger(this.holdCount)) {
-            return Promise.resolve(this.holdCount);
-        }
-
-        let method = 'open-ils.circ.bre.holds.count';
-        let target = this.id;
-
-        if (this.metabibId) {
-            method = 'open-ils.circ.mmr.holds.count';
-            target = this.metabibId;
-        }
-
-        return this.net.request(
-            'open-ils.circ', method, target
-        ).toPromise().then(count => this.holdCount = count);
-    }
-
     // Get -> Set -> Return bib-level call number
     getBibCallNumber(): Promise<string> {
 
@@ -141,194 +84,53 @@ export class BibRecordService {
         this.userCache = {};
     }
 
-    // Avoid fetching the MARC blob by specifying which fields on the
-    // bre to select.  Note that fleshed fields are implicitly selected.
-    fetchableBreFields(): string[] {
-        return this.idl.classes.bre.fields
-            .filter(f => !f.virtual && f.name !== 'marc')
-            .map(f => f.name);
+    getBibSummary(id: number,
+        orgId?: number, isStaff?: boolean): Observable<BibRecordSummary> {
+        return this.getBibSummaries([id], orgId, isStaff);
     }
 
-    // Note when multiple IDs are provided, responses are emitted in order
-    // of receipt, not necessarily in the requested ID order.
-    getBibSummary(bibIds: number | number[],
-        orgId?: number, orgDepth?: number): Observable<BibRecordSummary> {
+    getBibSummaries(bibIds: number[],
+        orgId?: number, isStaff?: boolean): Observable<BibRecordSummary> {
 
-        const ids = [].concat(bibIds);
+        if (bibIds.length === 0) { return from([]); }
+        if (!orgId) { orgId = this.org.root().id(); }
 
-        if (ids.length === 0) {
-            return from([]);
-        }
+        let method = 'open-ils.search.biblio.record.catalog_summary';
+        if (isStaff) { method += '.staff'; }
 
-        return this.pcrud.search('bre', {id: ids},
-            {   flesh: 1,
-                flesh_fields: {bre: ['flat_display_entries', 'mattrs']},
-                select: {bre : this.fetchableBreFields()}
-            },
-            {anonymous: true} // skip unneccesary auth
-        ).pipe(mergeMap(bib => {
-            const summary = new BibRecordSummary(bib, orgId, orgDepth);
+        return this.net.request('open-ils.search', method, orgId, bibIds)
+        .pipe(map(bibSummary => {
+            const summary = new BibRecordSummary(bibSummary.record, orgId);
             summary.net = this.net; // inject
-            summary.ingest();
-            return this.getHoldingsSummary(bib.id(), orgId, orgDepth)
-            .then(holdingsSummary => {
-                summary.holdingsSummary = holdingsSummary;
-                return summary;
-            });
+            summary.display = bibSummary.display;
+            summary.attributes = bibSummary.attributes;
+            summary.holdCount = bibSummary.hold_count;
+            summary.holdingsSummary = bibSummary.copy_counts;
+            return summary;
         }));
     }
 
-    // A Metabib Summary is a BibRecordSummary with the lead record as
-    // its core bib record plus attributes (e.g. formats) from related
-    // records.
-    getMetabibSummary(metabibIds: number | number[],
-        orgId?: number, orgDepth?: number): Observable<BibRecordSummary> {
-
-        const ids = [].concat(metabibIds);
-
-        if (ids.length === 0) {
-            return from([]);
-        }
-
-        return this.pcrud.search('mmr', {id: ids},
-            {flesh: 1, flesh_fields: {mmr: ['source_maps']}},
-            {anonymous: true}
-        ).pipe(mergeMap(mmr => this.compileMetabib(mmr, orgId, orgDepth)));
-    }
-
-    // 'metabib' must have its "source_maps" field fleshed.
-    // Get bib summaries for all related bib records so we can
-    // extract data that must be appended to the master record summary.
-    compileMetabib(metabib: IdlObject,
-        orgId?: number, orgDepth?: number): Observable<BibRecordSummary> {
-
-        // TODO: Create an API similar to the one that builds a combined
-        // mods blob for metarecords, except using display fields, etc.
-        // For now, this seems to get the job done.
-
-        // Non-master records
-        const relatedBibIds = metabib.source_maps()
-            .map(m => m.source())
-            .filter(id => id !== metabib.master_record());
+    getMetabibSummaries(metabibIds: number[],
+        orgId?: number, isStaff?: boolean): Observable<BibRecordSummary> {
 
-        let observer;
-        const observable = new Observable<BibRecordSummary>(o => observer = o);
+        if (metabibIds.length === 0) { return from([]); }
+        if (!orgId) { orgId = this.org.root().id(); }
 
-        // NOTE: getBibSummary calls getHoldingsSummary against
-        // the bib record unnecessarily.  It's called again below.
-        // Reconsider this approach (see also note above about API).
-        this.getBibSummary(metabib.master_record(), orgId, orgDepth)
-        .subscribe(summary => {
-            summary.metabibId = Number(metabib.id());
-            summary.metabibRecords =
-                metabib.source_maps().map(m => Number(m.source()));
+        let method = 'open-ils.search.biblio.metabib.catalog_summary';
+        if (isStaff) { method += '.staff'; }
 
-            let promise;
-
-            if (relatedBibIds.length > 0) {
-
-                // Grab data for MR bib summary augmentation
-                promise = this.pcrud.search('mraf', {id: relatedBibIds})
-                    .pipe(tap(attr => summary.record.mattrs().push(attr)))
-                    .toPromise();
-            } else {
-
-                // Metarecord has only one constituent bib.
-                promise = Promise.resolve();
-            }
-
-            promise.then(() => {
-
-                // Re-compile with augmented data
-                summary.compileRecordAttrs();
-
-                // Fetch holdings data for the metarecord
-                this.getHoldingsSummary(metabib.id(), orgId, orgDepth, true)
-                .then(holdingsSummary => {
-                    summary.holdingsSummary = holdingsSummary;
-                    observer.next(summary);
-                    observer.complete();
-                });
-            });
-        });
-
-        return observable;
-    }
-
-    // Flesh the creator and editor fields.
-    // Handling this separately lets us pull from the cache and
-    // avoids the requirement that the main bib query use a staff
-    // (VIEW_USER) auth token.
-    fleshBibUsers(records: IdlObject[]): Promise<void> {
-
-        const search = [];
-
-        records.forEach(rec => {
-            ['creator', 'editor'].forEach(field => {
-                const id = rec[field]();
-                if (Number.isInteger(id)) {
-                    if (this.userCache[id]) {
-                        rec[field](this.userCache[id]);
-                    } else if (!search.includes(id)) {
-                        search.push(id);
-                    }
-                }
-            });
-        });
-
-        if (search.length === 0) {
-            return Promise.resolve();
-        }
-
-        return this.pcrud.search('au', {id: search})
-        .pipe(map(user => {
-            this.userCache[user.id()] = user;
-            records.forEach(rec => {
-                if (user.id() === rec.creator()) {
-                    rec.creator(user);
-                }
-                if (user.id() === rec.editor()) {
-                    rec.editor(user);
-                }
-            });
-        })).toPromise();
-    }
-
-    getHoldingsSummary(recordId: number,
-        orgId: number, orgDepth: number, isMetarecord?: boolean): Promise<any> {
-
-        const holdingsSummary = [];
-
-        return this.unapi.getAsXmlDocument({
-            target: isMetarecord ? 'mmr' : 'bre',
-            id: recordId,
-            extras: '{holdings_xml}',
-            format: 'holdings_xml',
-            orgId: orgId,
-            depth: orgDepth
-        }).then(xmlDoc => {
-
-            // namespace resolver
-            const resolver: any = (prefix: string): string => {
-                return NAMESPACE_MAPS[prefix] || null;
-            };
-
-            // Extract the holdings data from the unapi xml doc
-            const result = xmlDoc.evaluate(HOLDINGS_XPATH,
-                xmlDoc, resolver, XPathResult.ANY_TYPE, null);
-
-            let node;
-            while (node = result.iterateNext()) {
-                const counts = {type : node.getAttribute('type')};
-                ['depth', 'org_unit', 'transcendant',
-                    'available', 'count', 'unshadow'].forEach(field => {
-                    counts[field] = Number(node.getAttribute(field));
-                });
-                holdingsSummary.push(counts);
-            }
-
-            return holdingsSummary;
-        });
+        return this.net.request('open-ils.search', method, orgId, metabibIds)
+        .pipe(map(metabibSummary => {
+            const summary = new BibRecordSummary(metabibSummary.record, orgId);
+            summary.net = this.net; // inject
+            summary.metabibId = Number(metabibSummary.metabib_id);
+            summary.metabibRecords = metabibSummary.metabib_records;
+            summary.display = metabibSummary.display;
+            summary.attributes = metabibSummary.attributes;
+            summary.holdCount = metabibSummary.hold_count;
+            summary.holdingsSummary = metabibSummary.copy_counts;
+            return summary;
+        }));
     }
 }
 
index c80d0b2..805a0bb 100644 (file)
@@ -190,11 +190,11 @@ export class CatalogService {
         let observable: Observable<BibRecordSummary>;
 
         if (isMeta) {
-            observable = this.bibService.getMetabibSummary(
-                ctx.currentResultIds(), ctx.searchOrg.id(), depth);
+            observable = this.bibService.getMetabibSummaries(
+                ctx.currentResultIds(), ctx.searchOrg.id(), ctx.isStaff);
         } else {
-            observable = this.bibService.getBibSummary(
-                ctx.currentResultIds(), ctx.searchOrg.id(), depth);
+            observable = this.bibService.getBibSummaries(
+                ctx.currentResultIds(), ctx.searchOrg.id(), ctx.isStaff);
         }
 
         return observable.pipe(map(summary => {
@@ -239,9 +239,9 @@ export class CatalogService {
             // them to bib IDs for highlighting.
             ids = ctx.currentResultIds();
             if (ctx.termSearch.groupByMetarecord) {
-                ids = ids.map(mrId =>
-                    ctx.result.records.filter(r => mrId === r.metabibId)[0].id
-                );
+                // The 4th slot in the result ID reports the master record
+                // for the metarecord in question.  Sometimes it's null?
+                ids = ctx.result.ids.map(id => id[4]).filter(id => id !== null);
             }
         }
 
index dfbee69..10060e0 100644 (file)
@@ -118,28 +118,24 @@ export class QueuedRecordMatchesComponent implements OnInit {
                 });
 
                 const bibSummaries: {[id: number]: BibRecordSummary} = {};
-                this.bib.getBibSummary(recIds).subscribe(
+                this.bib.getBibSummaries(recIds).subscribe(
                     summary => bibSummaries[summary.id] = summary,
                     err => {},
                     ()  => {
-                        this.bib.fleshBibUsers(
-                            Object.values(bibSummaries).map(sum => sum.record)
-                        ).then(() => {
-                            matches.forEach(match => {
-                                const row = {
-                                    id: match.id(),
-                                    eg_record: match.eg_record(),
-                                    bre_quality: match.quality(),
-                                    vqbr_quality: this.queuedRecord.quality(),
-                                    match_score: match.match_score(),
-                                    bib_summary: bibSummaries[match.eg_record()]
-                                };
-
-                                observer.next(row);
-                            });
-
-                            observer.complete();
+                        matches.forEach(match => {
+                            const row = {
+                                id: match.id(),
+                                eg_record: match.eg_record(),
+                                bre_quality: match.quality(),
+                                vqbr_quality: this.queuedRecord.quality(),
+                                match_score: match.match_score(),
+                                bib_summary: bibSummaries[match.eg_record()]
+                            };
+
+                            observer.next(row);
                         });
+
+                        observer.complete();
                     }
                 );
             });
index 464f443..9c0dab3 100644 (file)
@@ -119,9 +119,9 @@ export class CnBrowseResultsComponent implements OnInit, OnDestroy {
         };
 
         const bres: IdlObject[] = [];
-        this.bib.getBibSummary(
+        this.bib.getBibSummaries(
             bibIds.filter(distinct),
-            this.searchContext.searchOrg.id(), depth
+            this.searchContext.searchOrg.id(), this.searchContext.isStaff
         ).subscribe(
             summary => {
                 // Response order not guaranteed.  Match the summary
@@ -134,10 +134,6 @@ export class CnBrowseResultsComponent implements OnInit, OnDestroy {
 
                 // Use _ since result is an 'acn' object.
                 bibResults.forEach(r => r._bibSummary = summary);
-            },
-            err => {},
-            ()  => {
-                this.bib.fleshBibUsers(bres);
             }
         );
     }
index e68e017..fc7ba54 100644 (file)
@@ -156,7 +156,6 @@ export class RecordComponent implements OnInit {
         .then(summary => {
             this.summary =
                 this.staffCat.currentDetailRecordSummary = summary;
-            this.bib.fleshBibUsers([summary.record]);
         });
     }
 
index 65209a0..8d839e1 100644 (file)
       <div class="col-lg-2">
         <div class="row" [ngClass]="{'pt-2':copyIndex > 0}" 
           *ngFor="let copyCount of summary.holdingsSummary; let copyIdx = index">
-          <div class="w-100" *ngIf="copyCount.type == 'staff'">
-            <div class="float-left text-left w-50">
-              <span class="pr-1">
-              {{copyCount.available}} / {{copyCount.count}} items
-              </span>
-            </div>
-            <div class="float-left w-50">
-              @ {{orgName(copyCount.org_unit)}}
-            </div>
+          <div class="float-left text-left w-50">
+            <span class="pr-1">
+            {{copyCount.available}} / {{copyCount.count}} items
+            </span>
+          </div>
+          <div class="float-left w-50">
+            @ {{orgName(copyCount.org_unit)}}
           </div>
         </div>
       </div>
index 8cb7f03..664d366 100644 (file)
@@ -43,7 +43,6 @@ export class ResultRecordComponent implements OnInit, OnDestroy {
 
     ngOnInit() {
         this.searchContext = this.staffCat.searchContext;
-        this.summary.getHoldCount();
         this.isRecordSelected = this.basket.hasRecordId(this.summary.id);
 
         // Watch for basket changes caused by other components
index 41804cc..515a376 100644 (file)
       </div>
       <div
         [ngClass]="{'col-lg-10': !searchContext.basket, 'col-lg-12': searchContext.basket}">
-        <div *ngIf="shouldStartRendering()">
-          <div *ngFor="let summary of searchContext.result.records; let idx = index">
-            <div *ngIf="summary">
-              <eg-catalog-result-record [summary]="summary" [index]="idx">
-              </eg-catalog-result-record>
-            </div>
+        <div *ngFor="let summary of searchContext.result.records; let idx = index">
+          <div *ngIf="summary">
+            <eg-catalog-result-record [summary]="summary" [index]="idx">
+            </eg-catalog-result-record>
           </div>
         </div>
       </div>
index 50ed791..edcb381 100644 (file)
@@ -100,39 +100,11 @@ export class ResultsComponent implements OnInit, OnDestroy {
             this.cat.search(this.searchContext)
             .then(ok => {
                 this.cat.fetchFacets(this.searchContext);
-                this.cat.fetchBibSummaries(this.searchContext)
-                .then(ok2 => this.fleshSearchResults());
+                this.cat.fetchBibSummaries(this.searchContext);
             });
         }
     }
 
-    // Records file into place randomly as the server returns data.
-    // To reduce page display shuffling, avoid showing the list of
-    // records until the first few are ready to render.
-    shouldStartRendering(): boolean {
-
-        if (this.searchHasResults()) {
-            const pageCount = this.searchContext.currentResultIds().length;
-            switch (pageCount) {
-                case 1:
-                    return this.searchContext.result.records[0];
-                default:
-                    return this.searchContext.result.records[0]
-                        && this.searchContext.result.records[1];
-            }
-        }
-
-        return false;
-    }
-
-    fleshSearchResults(): void {
-        const records = this.searchContext.result.records;
-        if (!records || records.length === 0) { return; }
-
-        // Flesh the creator / editor fields with the user object.
-        this.bib.fleshBibUsers(records.map(r => r.record));
-    }
-
     searchIsDone(): boolean {
         return this.searchContext.searchState === CatalogSearchState.COMPLETE;
     }
index 920b008..d4ca900 100644 (file)
                 </div>
                 <div class="checkbox pl-3">
                   <label>
-                    <input type="checkbox" [(ngModel)]="context.termSearch.global"/>
+                    <input type="checkbox" [(ngModel)]="context.global"/>
                     <span class="pl-1" i18n>Results from All Libraries</span>
                   </label>
                 </div>
index 39b8944..84719fd 100644 (file)
@@ -66,7 +66,6 @@ export class BibSummaryComponent implements OnInit {
         this.bib.getBibSummary(this.recordId).toPromise()
         .then(summary => {
             summary.getBibCallNumber();
-            this.bib.fleshBibUsers([summary.record]);
             this.summary = summary;
         });
     }
index 159ecd7..b07440e 100644 (file)
@@ -13,9 +13,6 @@ use Encode;
 
 use OpenSRF::Utils::Logger qw/:logger/;
 
-
-use OpenSRF::Utils::JSON;
-
 use Time::HiRes qw(time sleep);
 use OpenSRF::EX qw(:try);
 use Digest::MD5 qw(md5_hex);
@@ -2744,5 +2741,158 @@ sub mk_copy_query {
 }
 
 
+__PACKAGE__->register_method(
+    method    => 'catalog_record_summary',
+    api_name  => 'open-ils.search.biblio.record.catalog_summary',
+    stream    => 1,
+    max_bundle_count => 1,
+    signature => {
+        desc   => 'Stream of record data suitable for catalog display',
+        params => [
+            {desc => 'Context org unit ID', type => 'number'},
+            {desc => 'Array of Record IDs', type => 'array'}
+        ],
+        return => { 
+            desc => q/
+                Stream of record summary objects including id, record,
+                hold_count, copy_counts, display (metabib display
+                fields), attributes (metabib record attrs), plus
+                metabib_id and metabib_records for the metabib variant.
+            /
+        }
+    }
+);
+__PACKAGE__->register_method(
+    method    => 'catalog_record_summary',
+    api_name  => 'open-ils.search.biblio.record.catalog_summary.staff',
+    stream    => 1,
+    max_bundle_count => 1,
+    signature => q/see open-ils.search.biblio.record.catalog_summary/
+);
+__PACKAGE__->register_method(
+    method    => 'catalog_record_summary',
+    api_name  => 'open-ils.search.biblio.metabib.catalog_summary',
+    stream    => 1,
+    max_bundle_count => 1,
+    signature => q/see open-ils.search.biblio.record.catalog_summary/
+);
+
+__PACKAGE__->register_method(
+    method    => 'catalog_record_summary',
+    api_name  => 'open-ils.search.biblio.metabib.catalog_summary.staff',
+    stream    => 1,
+    max_bundle_count => 1,
+    signature => q/see open-ils.search.biblio.record.catalog_summary/
+);
+
+
+sub catalog_record_summary {
+    my ($self, $client, $org_id, $record_ids) = @_;
+    my $e = new_editor();
+
+    my $is_meta = ($self->api_name =~ /metabib/);
+    my $is_staff = ($self->api_name =~ /staff/);
+
+    my $holds_method = $is_meta ? 
+        'open-ils.circ.mmr.holds.count' : 
+        'open-ils.circ.bre.holds.count';
+
+    my $copy_method = $is_meta ? 
+        'open-ils.search.biblio.metarecord.copy_count':
+        'open-ils.search.biblio.record.copy_count';
+
+    $copy_method .= '.staff' if $is_staff;
+
+    $copy_method = $self->method_lookup($copy_method); # local method
+
+    for my $rec_id (@$record_ids) {
+
+        my $response = $is_meta ? 
+            get_one_metarecord_summary($e, $rec_id) :
+            get_one_record_summary($e, $rec_id);
+
+        ($response->{copy_counts}) = $copy_method->run($org_id, $rec_id);
+
+        $response->{hold_count} = 
+            $U->simplereq('open-ils.circ', $holds_method, $rec_id);
+
+        $client->respond($response);
+    }
+
+    return undef;
+}
+
+# Start with a bib summary and augment the data with additional
+# metarecord content.
+sub get_one_metarecord_summary {
+    my ($e, $rec_id) = @_;
+
+    my $meta = $e->retrieve_metabib_metarecord($rec_id) or return {};
+    my $maps = $e->search_metabib_metarecord_source_map({metarecord => $rec_id});
+
+    my $bre_id = $meta->master_record; 
+
+    my $response = get_one_record_summary($e, $bre_id);
+
+    $response->{metabib_id} = $rec_id;
+    $response->{metabib_records} = [map {$_->source} @$maps];
+
+    my @other_bibs = map {$_->source} grep {$_->source != $bre_id} @$maps;
+
+    # Augment the record attributes with those of all of the records
+    # linked to this metarecord.
+    if (@other_bibs) {
+        my $attrs = $e->search_metabib_record_attr_flat({id => \@other_bibs});
+
+        my $attributes = $response->{attributes};
+
+        for my $attr (@$attrs) {
+            $attributes->{$attr->attr} = [] unless $attributes->{$attr->attr};
+            push(@{$attributes->{$attr->attr}}, $attr->value) # avoid dupes
+                unless grep {$_ eq $attr->value} @{$attributes->{$attr->attr}};
+        }
+    }
+
+    return $response;
+}
+
+sub get_one_record_summary {
+    my ($e, $rec_id) = @_;
+
+    my $bre = $e->retrieve_biblio_record_entry([$rec_id, {
+        flesh => 1,
+        flesh_fields => {
+            bre => [qw/compressed_display_entries mattrs creator editor/]
+        }
+    }]) or return {};
+
+    # Compressed display fields are pachaged as JSON
+    my $display = {};
+    $display->{$_->name} = OpenSRF::Utils::JSON->JSON2perl($_->value)
+        foreach @{$bre->compressed_display_entries};
+
+    # Create an object of 'mraf' attributes.
+    # Any attribute can be multi so dedupe and array-ify all of them.
+    my $attributes = {};
+    for my $attr (@{$bre->mattrs}) {
+        $attributes->{$attr->attr} = {} unless $attributes->{$attr->attr};
+        $attributes->{$attr->attr}->{$attr->value} = 1; # avoid dupes
+    }
+    $attributes->{$_} = [keys %{$attributes->{$_}}] for keys %$attributes;
+
+    # clear bulk
+    $bre->clear_marc;
+    $bre->clear_mattrs;
+    $bre->clear_compressed_display_entries;
+
+    return {
+        id => $rec_id,
+        record => $bre,
+        display => $display,
+        attributes => $attributes
+    };
+}
+
+
 1;