Skip to content

Commit f2cae2a

Browse files
committed
feat!: transform memberOf and rootCollection to objects with id and name
BREAKING CHANGE: memberOf and rootCollection fields now return objects with {id, name} instead of string IDs. When the referenced parent entity doesn't exist, the field returns null. - Add EntityReference type for memberOf/rootCollection fields - Add BaseEntity type for intermediate transformation state - Add resolveEntityReferences helper for batch-fetching parent entities - Update StandardEntity type to use EntityReference | null This provides richer entity data without requiring additional API calls to resolve parent entity names.
1 parent 551a362 commit f2cae2a

File tree

13 files changed

+328
-28
lines changed

13 files changed

+328
-28
lines changed

src/routes/__snapshots__/entities.test.ts.snap

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,40 @@ exports[`Entities Route > GET /entities > should return entities with default pa
5757
"description": "Second test entity",
5858
"entityType": "http://pcdm.org/models#Object",
5959
"id": "http://example.com/entity/2",
60-
"memberOf": "http://example.com/entity/1",
60+
"memberOf": {
61+
"id": "http://example.com/entity/1",
62+
"name": "Test Entity 1",
63+
},
6164
"metadataLicenseId": "https://creativecommons.org/licenses/by/4.0/",
6265
"name": "Test Entity 2",
63-
"rootCollection": "http://example.com/entity/1",
66+
"rootCollection": {
67+
"id": "http://example.com/entity/1",
68+
"name": "Test Entity 1",
69+
},
6470
},
6571
],
6672
"total": 2,
6773
}
6874
`;
75+
76+
exports[`Entities Route > GET /entities > should return null for memberOf/rootCollection when parent entity not found 1`] = `
77+
{
78+
"entities": [
79+
{
80+
"access": {
81+
"content": true,
82+
"metadata": true,
83+
},
84+
"contentLicenseId": "https://creativecommons.org/licenses/by/4.0/",
85+
"description": "Entity with missing parent",
86+
"entityType": "http://pcdm.org/models#Object",
87+
"id": "http://example.com/entity/1",
88+
"memberOf": null,
89+
"metadataLicenseId": "https://creativecommons.org/licenses/by/4.0/",
90+
"name": "Test Entity 1",
91+
"rootCollection": null,
92+
},
93+
],
94+
"total": 1,
95+
}
96+
`;

src/routes/__snapshots__/entity.test.ts.snap

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,20 @@ exports[`Entity Route > GET /entity/:id > should return entity when found 1`] =
5555
"rootCollection": null,
5656
}
5757
`;
58+
59+
exports[`Entity Route > GET /entity/:id > should return null for memberOf/rootCollection when parent entity not found 1`] = `
60+
{
61+
"access": {
62+
"content": true,
63+
"metadata": true,
64+
},
65+
"contentLicenseId": "https://creativecommons.org/licenses/by/4.0/",
66+
"description": "A test entity",
67+
"entityType": "http://pcdm.org/models#Object",
68+
"id": "http://example.com/entity/123",
69+
"memberOf": null,
70+
"metadataLicenseId": "https://creativecommons.org/licenses/by/4.0/",
71+
"name": "Test Entity",
72+
"rootCollection": null,
73+
}
74+
`;

src/routes/__snapshots__/search.test.ts.snap

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,32 @@ exports[`Search Route > POST /search > should perform basic search successfully
111111
}
112112
`;
113113

114+
exports[`Search Route > POST /search > should return null for memberOf/rootCollection when parent entity not found 1`] = `
115+
{
116+
"entities": [
117+
{
118+
"access": {
119+
"content": true,
120+
"metadata": true,
121+
},
122+
"contentLicenseId": "https://creativecommons.org/licenses/by/4.0/",
123+
"description": "Entity with missing parent",
124+
"entityType": "http://pcdm.org/models#Object",
125+
"id": "http://example.com/entity/1",
126+
"memberOf": null,
127+
"metadataLicenseId": "https://creativecommons.org/licenses/by/4.0/",
128+
"name": "Test Entity 1",
129+
"rootCollection": null,
130+
"searchExtra": {
131+
"score": 1.5,
132+
},
133+
},
134+
],
135+
"searchTime": 10,
136+
"total": 1,
137+
}
138+
`;
139+
114140
exports[`Search Route > POST /search > should skip entities not found in database and log warning 1`] = `
115141
{
116142
"entities": [

src/routes/entities.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,5 +280,40 @@ describe('Entities Route', () => {
280280
expect(response.statusCode).toBe(200);
281281
expect(body).toMatchSnapshot();
282282
});
283+
284+
it('should return null for memberOf/rootCollection when parent entity not found', async () => {
285+
const mockEntities = [
286+
{
287+
id: 1,
288+
rocrateId: 'http://example.com/entity/1',
289+
name: 'Test Entity 1',
290+
description: 'Entity with missing parent',
291+
entityType: 'http://pcdm.org/models#Object',
292+
fileId: null,
293+
memberOf: 'http://example.com/entity/deleted',
294+
rootCollection: 'http://example.com/entity/deleted',
295+
metadataLicenseId: 'https://creativecommons.org/licenses/by/4.0/',
296+
contentLicenseId: 'https://creativecommons.org/licenses/by/4.0/',
297+
createdAt: new Date(),
298+
updatedAt: new Date(),
299+
rocrate: {},
300+
meta: {},
301+
},
302+
];
303+
304+
// First call returns entities, second call (for reference resolution) returns empty
305+
prisma.entity.findMany.mockResolvedValueOnce(mockEntities);
306+
prisma.entity.findMany.mockResolvedValueOnce([]);
307+
prisma.entity.count.mockResolvedValue(1);
308+
309+
const response = await fastify.inject({
310+
method: 'GET',
311+
url: '/entities',
312+
});
313+
const body = JSON.parse(response.body);
314+
315+
expect(response.statusCode).toBe(200);
316+
expect(body).toMatchSnapshot();
317+
});
283318
});
284319
});

src/routes/entities.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { FastifyPluginAsync } from 'fastify';
22
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
33
import { z } from 'zod/v4';
4-
import { baseEntityTransformer } from '../transformers/default.js';
4+
import { baseEntityTransformer, resolveEntityReferences } from '../transformers/default.js';
55
import type { AccessTransformer, EntityTransformer } from '../types/transformers.js';
66
import { createInternalError } from '../utils/errors.js';
77

@@ -65,10 +65,18 @@ const entities: FastifyPluginAsync<EntitiesRouteOptions> = async (fastify, opts)
6565

6666
const total = await fastify.prisma.entity.count({ where });
6767

68+
// Resolve memberOf and rootCollection references
69+
const refMap = await resolveEntityReferences(dbEntities, fastify.prisma);
70+
6871
// Apply transformers to each entity: base -> access -> additional
6972
const entities = await Promise.all(
7073
dbEntities.map(async (dbEntity) => {
71-
const standardEntity = baseEntityTransformer(dbEntity);
74+
const base = baseEntityTransformer(dbEntity);
75+
const standardEntity = {
76+
...base,
77+
memberOf: base.memberOf ? (refMap.get(base.memberOf) ?? null) : null,
78+
rootCollection: base.rootCollection ? (refMap.get(base.rootCollection) ?? null) : null,
79+
};
7280
const authorisedEntity = await accessTransformer(standardEntity, {
7381
request,
7482
fastify,

src/routes/entity.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,5 +128,37 @@ describe('Entity Route', () => {
128128
expect(response.statusCode).toBe(200);
129129
expect(body).toMatchSnapshot();
130130
});
131+
132+
it('should return null for memberOf/rootCollection when parent entity not found', async () => {
133+
const mockEntity = {
134+
id: 1,
135+
rocrateId: 'http://example.com/entity/123',
136+
name: 'Test Entity',
137+
description: 'A test entity',
138+
entityType: 'http://pcdm.org/models#Object',
139+
fileId: null,
140+
memberOf: 'http://example.com/entity/deleted',
141+
rootCollection: 'http://example.com/entity/deleted',
142+
metadataLicenseId: 'https://creativecommons.org/licenses/by/4.0/',
143+
contentLicenseId: 'https://creativecommons.org/licenses/by/4.0/',
144+
createdAt: new Date(),
145+
updatedAt: new Date(),
146+
rocrate: {},
147+
meta: {},
148+
};
149+
150+
// First call returns the entity, second call (for reference resolution) returns empty
151+
prisma.entity.findFirst.mockResolvedValue(mockEntity);
152+
prisma.entity.findMany.mockResolvedValue([]);
153+
154+
const response = await fastify.inject({
155+
method: 'GET',
156+
url: `/entity/${encodeURIComponent('http://example.com/entity/123')}`,
157+
});
158+
const body = JSON.parse(response.body);
159+
160+
expect(response.statusCode).toBe(200);
161+
expect(body).toMatchSnapshot();
162+
});
131163
});
132164
});

src/routes/entity.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { FastifyPluginAsync } from 'fastify';
22
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
33
import { z } from 'zod/v4';
4-
import { baseEntityTransformer } from '../transformers/default.js';
4+
import { baseEntityTransformer, resolveEntityReferences } from '../transformers/default.js';
55
import type { AccessTransformer, EntityTransformer } from '../types/transformers.js';
66
import { createInternalError, createNotFoundError } from '../utils/errors.js';
77

@@ -37,7 +37,15 @@ const entity: FastifyPluginAsync<EntityRouteOptions> = async (fastify, opts) =>
3737
return reply.code(404).send(createNotFoundError('The requested entity was not found', id));
3838
}
3939

40-
const standardEntity = baseEntityTransformer(entity);
40+
// Resolve memberOf and rootCollection references
41+
const refMap = await resolveEntityReferences([entity], fastify.prisma);
42+
43+
const base = baseEntityTransformer(entity);
44+
const standardEntity = {
45+
...base,
46+
memberOf: base.memberOf ? (refMap.get(base.memberOf) ?? null) : null,
47+
rootCollection: base.rootCollection ? (refMap.get(base.rootCollection) ?? null) : null,
48+
};
4149
const authorisedEntity = await accessTransformer(standardEntity, {
4250
request,
4351
fastify,

src/routes/search.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -803,5 +803,62 @@ describe('Search Route', () => {
803803
expect(body.geohashGrid).toBeUndefined();
804804
expect(body.entities).toHaveLength(1);
805805
});
806+
807+
it('should return null for memberOf/rootCollection when parent entity not found', async () => {
808+
const mockEntities = [
809+
{
810+
id: 1,
811+
fileId: null,
812+
meta: {},
813+
rocrate: '',
814+
rocrateId: 'http://example.com/entity/1',
815+
name: 'Test Entity 1',
816+
description: 'Entity with missing parent',
817+
entityType: 'http://pcdm.org/models#Object',
818+
memberOf: 'http://example.com/entity/deleted',
819+
rootCollection: 'http://example.com/entity/deleted',
820+
metadataLicenseId: 'https://creativecommons.org/licenses/by/4.0/',
821+
contentLicenseId: 'https://creativecommons.org/licenses/by/4.0/',
822+
createdAt: new Date('2024-01-01'),
823+
updatedAt: new Date('2024-01-01'),
824+
},
825+
];
826+
827+
const mockSearchResponse = {
828+
body: {
829+
took: 10,
830+
hits: {
831+
total: { value: 1 },
832+
hits: [
833+
{
834+
_score: 1.5,
835+
_source: {
836+
rocrateId: 'http://example.com/entity/1',
837+
},
838+
},
839+
],
840+
},
841+
aggregations: {},
842+
},
843+
};
844+
845+
// @ts-expect-error TS is looking at the wrong function signature
846+
opensearch.search.mockResolvedValue(mockSearchResponse);
847+
// First findMany returns the entities, second (for reference resolution) returns empty
848+
prisma.entity.findMany.mockResolvedValueOnce(mockEntities);
849+
prisma.entity.findMany.mockResolvedValueOnce([]);
850+
851+
const response = await fastify.inject({
852+
method: 'POST',
853+
url: '/search',
854+
payload: {
855+
query: 'test',
856+
},
857+
});
858+
const body = JSON.parse(response.body);
859+
860+
expect(response.statusCode).toBe(200);
861+
expect(body).toMatchSnapshot();
862+
});
806863
});
807864
});

src/routes/search.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { Search_Request, Search_RequestBody } from '@opensearch-project/ope
44
import type { FastifyPluginAsync } from 'fastify';
55
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
66
import { z } from 'zod/v4';
7-
import { baseEntityTransformer } from '../transformers/default.js';
7+
import { baseEntityTransformer, resolveEntityReferences } from '../transformers/default.js';
88
import type { AccessTransformer, EntityTransformer } from '../types/transformers.js';
99
import { createInternalError } from '../utils/errors.js';
1010

@@ -219,6 +219,9 @@ const search: FastifyPluginAsync<SearchRouteOptions> = async (fastify, opts) =>
219219

220220
const entityMap = new Map(dbEntities.map((entity) => [entity.rocrateId, entity]));
221221

222+
// Resolve memberOf and rootCollection references
223+
const refMap = await resolveEntityReferences(dbEntities, fastify.prisma);
224+
222225
const entities = await Promise.all(
223226
response.body.hits.hits.map(async (hit) => {
224227
if (!hit._source?.rocrateId) {
@@ -232,7 +235,12 @@ const search: FastifyPluginAsync<SearchRouteOptions> = async (fastify, opts) =>
232235
return null;
233236
}
234237

235-
const standardEntity = baseEntityTransformer(dbEntity);
238+
const base = baseEntityTransformer(dbEntity);
239+
const standardEntity = {
240+
...base,
241+
memberOf: base.memberOf ? (refMap.get(base.memberOf) ?? null) : null,
242+
rootCollection: base.rootCollection ? (refMap.get(base.rootCollection) ?? null) : null,
243+
};
236244
const authorisedEntity = await accessTransformer(standardEntity, {
237245
request,
238246
fastify,

src/test/integration.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ describe('Integration Tests', () => {
5353
expect(body.id).toBe('http://example.com/entity/4');
5454
expect(body.name).toBe('test-audio.wav');
5555
expect(body.entityType).toBe('http://schema.org/MediaObject');
56-
expect(body.memberOf).toBe('http://example.com/entity/2');
57-
expect(body.rootCollection).toBe('http://example.com/entity/1');
56+
expect(body.memberOf?.id).toBe('http://example.com/entity/2');
57+
expect(body.rootCollection?.id).toBe('http://example.com/entity/1');
5858
});
5959

6060
it('should return 404 for non-existent entity', async () => {
@@ -173,7 +173,7 @@ describe('Integration Tests', () => {
173173
expect(body.entities).toHaveLength(1);
174174
expect(body.entities[0].id).toBe('http://example.com/entity/4');
175175
expect(body.entities[0].name).toBe('test-audio.wav');
176-
expect(body.entities[0].memberOf).toBe('http://example.com/entity/2');
176+
expect(body.entities[0].memberOf?.id).toBe('http://example.com/entity/2');
177177
});
178178

179179
it('should filter File entities by memberOf (Collection parent)', async () => {
@@ -194,7 +194,7 @@ describe('Integration Tests', () => {
194194
expect(body.entities).toHaveLength(1);
195195
expect(body.entities[0].id).toBe('http://example.com/entity/5');
196196
expect(body.entities[0].name).toBe('collection-metadata.csv');
197-
expect(body.entities[0].memberOf).toBe('http://example.com/entity/1');
197+
expect(body.entities[0].memberOf?.id).toBe('http://example.com/entity/1');
198198
});
199199

200200
it('should handle pagination', async () => {

0 commit comments

Comments
 (0)