Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/API/src/db/occurrence/dto/search-occurrence-query.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export interface SearchOccurrenceQueryDto {
id?: string;
country?: string;
species?: string;
season?: string;
insecticide?: string;
absence?: string;
abundance?: string;
bionomics?: string;
adult?: string;
larval?: string;
timeline?: string;
take?: string;
skip?: string;
fields?: string;
}
99 changes: 99 additions & 0 deletions src/API/src/db/occurrence/occurrence-response.mapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { Occurrence } from './entities/occurrence.entity';
import { OccurrenceService } from './occurrence.service';
import { OccurrenceReturn } from './occurrenceReturn';

export function mapOccurrencesToReturnItems(
items: Occurrence[],
occurrenceService: OccurrenceService,
minimalFields = true,
): OccurrenceReturn[] {
const relationObject = occurrenceService.getOccurrenceFields(true);
const excludeColumns = {
parent: [],
relations: {
dataset: [
'id',
'status',
'UpdatedBy',
'UpdatedAt',
'ReviewedBy',
'ReviewedAt',
'ApprovedBy',
'ApprovedAt',
],
site: ['longitude_4', 'longitude_5'],
},
};

const includeColumn = (
isParentProperty: boolean,
relationName: string,
columnName: string,
) => {
let cols: any;
if (isParentProperty) {
cols = excludeColumns['parent'];
} else {
cols = excludeColumns['relations'][relationName];
}
if (cols === '*') return false;
if (Array.isArray(cols) && cols.includes(columnName)) return false;
return true;
};

const selectAllFields = (record: object, destinationObject: object) => {
Object.keys(record).map((dataProperty) => {
if (Object.keys(relationObject).includes(dataProperty)) {
const relationFields = relationObject[dataProperty];
relationFields.map((relationField) => {
if (
relationField === 'id' ||
!includeColumn(false, dataProperty, relationField)
) {
// omitted
} else {
const key = `${dataProperty}_${relationField}`;
Object.assign(destinationObject, {
[key]: record?.[dataProperty]?.[relationField] || null,
});
}
});
} else {
if (includeColumn(true, null, dataProperty)) {
Object.assign(destinationObject, {
[dataProperty]: record?.[dataProperty],
});
}
}
});
return destinationObject;
};

return items.map((x) => {
const obj = {
id: x.id,
species: x.recordedSpecies.species,
location: x.site.location,
binary_presence: x.binary_presence,
country: x.site.country,
year_start: x.year_start,
is_adult: !!x.adult_data,
is_larval: x.larval_data === 'True',
season_val: x.season_calc || x.season_given || '',
insecticide: x.insecticide_resistance_data,
control: x.sample?.control?.toString() || '',
abundance_data: x.abundance_data,
bio_data: x.bio_data,
display_name: x.recordedSpecies?.display_name,
category: x.recordedSpecies?.category,
color: x.recordedSpecies?.color,
//has_bionomics: x.bio_data,
};

if (!minimalFields) {
const extendedObject = selectAllFields(x, obj);
Object.assign(obj, extendedObject);
}
return obj;
});
}
115 changes: 115 additions & 0 deletions src/API/src/db/occurrence/occurrence-search.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import {
BadRequestException,
Controller,
Get,
NotFoundException,
Param,
Query,
} from '@nestjs/common';
import { OccurrenceService } from './occurrence.service';
import { SearchOccurrenceQueryDto } from './dto/search-occurrence-query.dto';
import { mapOccurrencesToReturnItems } from './occurrence-response.mapper';
import {
parseStringArray,
parseNullableStringArray,
parseNullableBooleanArray,
parseTimeline,
parseFields,
projectFields,
} from './search-query.utils';

/**
* GET /search/:entityType
* This controller only validates input, translates REST query params into the
* OccurrenceFilter shape, delegates to the existing service, and formats
* the response via the shared mapper (occurrence-response.mapper.ts) so
* REST and GraphQL responses stay identical in shape.
*/
@Controller('search')
export class OccurrenceSearchController {
constructor(private readonly occurrenceService: OccurrenceService) {}

@Get(':entityType')
async search(
@Param('entityType') entityType: string,
@Query() query: SearchOccurrenceQueryDto,
) {
// Manual validation
if (entityType.toLowerCase() !== 'occurrence') {
throw new BadRequestException(
`Unsupported entity type "${entityType}". Only "occurrence" is currently supported.`,
);
}

const fields = parseFields(query.fields);

// Single-record lookup by id — bypasses filters/pagination entirely and
// reuses the existing findOneById,
if (query.id) {
const occurrence = await this.occurrenceService.findOneById(query.id);
if (!occurrence) {
throw new NotFoundException(
`No occurrence found with id "${query.id}".`,
);
}
const [mapped] = mapOccurrencesToReturnItems(
[occurrence],
this.occurrenceService,
true,
);
return {
items: [projectFields(mapped, fields)],
total: 1,
hasMore: false,
};
}

const take = query.take ? parseInt(query.take, 10) : 100;
const skip = query.skip ? parseInt(query.skip, 10) : 0;
if (isNaN(take) || take < 1 || take > 5000) {
throw new BadRequestException(
'take must be a number between 1 and 5000.',
);
}
if (isNaN(skip) || skip < 0) {
throw new BadRequestException('skip must be a number >= 0.');
}

const filters: any = {
country: parseStringArray(query.country),
species: parseStringArray(query.species),
season: parseNullableStringArray(query.season),
insecticide: parseNullableStringArray(query.insecticide),
binary_presence: parseNullableStringArray(query.absence),
abundance_data: parseNullableStringArray(query.abundance),
bionomics: parseNullableBooleanArray(query.bionomics)?.filter(
(v) => v !== null,
),
isAdult: parseNullableBooleanArray(query.adult),
isLarval: parseNullableBooleanArray(query.larval),
...parseTimeline(query.timeline),
};

const bounds = { locationWindowActive: false };

const { items, total } = await this.occurrenceService.findOccurrences(
take,
skip,
filters,
bounds as any,
true,
);

const mappedItems = mapOccurrencesToReturnItems(
items,
this.occurrenceService,
true,
);

return {
items: mappedItems.map((item) => projectFields(item, fields)),
total,
hasMore: total > take + skip,
};
}
}
3 changes: 2 additions & 1 deletion src/API/src/db/occurrence/occurrence.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { AuthService } from 'src/auth/auth.service';
import { UserRoleService } from 'src/auth/user_role/user_role.service';
import { UserRole } from 'src/auth/user_role/user_role.entity';
import { OccurrenceController } from './occurrence.controller';
import { OccurrenceSearchController } from './occurrence-search.controller';
import { Dataset } from '../shared/entities/dataset.entity';
import { InsecticideResistanceBioassays } from '../insecticideResistance/entities/insecticideResistanceBioassays.entity';
import { InsecticideResistanceService } from '../insecticideResistance/insecticideResistance.service';
Expand Down Expand Up @@ -137,7 +138,7 @@ import { DynamicQueryModule } from '../shared/dynamic-query.module';
EditLogsModule,
DynamicQueryModule,
],
controllers: [OccurrenceController],
controllers: [OccurrenceController, OccurrenceSearchController],
providers: [
OccurrenceService,
OccurrenceResolver,
Expand Down
109 changes: 8 additions & 101 deletions src/API/src/db/occurrence/occurrence.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { Reference } from '../shared/entities/reference.entity';
import { ReferenceService } from '../shared/reference.service';
import { flattenOccurrenceRepoObject } from '../../export/utils/allDataCsvCreation';
import { OccurrenceReturn } from './occurrenceReturn';
import { mapOccurrencesToReturnItems } from './occurrence-response.mapper';
import { randomUUID } from 'crypto';
import { DoiService } from '../doi/doi.service';
import { DoiController } from '../doi/doi.controller';
Expand Down Expand Up @@ -164,117 +165,23 @@ export class OccurrenceResolver {
recordDownload?: boolean,
minimalFields = true,
) {
const effectiveBounds = bounds ?? { locationWindowActive: false };
const { items, total } = await this.occurrenceService.findOccurrences(
take,
skip,
filters,
bounds,
effectiveBounds,
minimalFields,
);
if (recordDownload) {
await this.occurrenceService.incrementDownload(items);
}

const relationObject = this.occurrenceService.getOccurrenceFields(true);
const excludeColumns = {
parent: [],
relations: {
// dataset: '*',
dataset: [
'id',
'status',
'UpdatedBy',
'UpdatedAt',
'ReviewedBy',
'ReviewedAt',
'ApprovedBy',
'ApprovedAt',
],
site: ['longitude_4', 'longitude_5'],
},
};

const includeColumn = (
isParentProperty: boolean,
relationName: string,
columnName: string,
) => {
let cols: any;
if (isParentProperty) {
cols = excludeColumns['parent'];
} else {
cols = excludeColumns['relations'][relationName];
}

if (cols === '*') return false;
if (Array.isArray(cols) && cols.includes(columnName)) return false;
return true;
};

/**
* Include other fields in addition to those specified in the Interface
* extend to other relations. This contradicts strict typing requirements of OccurrenceReturn but it
* was necessary so that we allow inclusion of related fields dynamically
* extended fields will be renamed to `relationName_relationFieldName`
*/
const selectAllFields = (record: object, destinationObject: object) => {
Object.keys(record).map((dataProperty) => {
// check if fld is a relation. If yes, loop through all fields for the relation
if (Object.keys(relationObject).includes(dataProperty)) {
const relationFields = relationObject[dataProperty];
relationFields.map((relationField) => {
if (
relationField === 'id' ||
!includeColumn(false, dataProperty, relationField)
) {
// do nothing since field should not be included
} else {
const key = `${dataProperty}_${relationField}`;
Object.assign(destinationObject, {
[key]: record?.[dataProperty]?.[relationField] || null,
});
}
});
} else {
if (includeColumn(true, null, dataProperty)) {
Object.assign(destinationObject, {
[dataProperty]: record?.[dataProperty],
});
}
}
});
return destinationObject;
};

const returnItems: OccurrenceReturn[] = items.map((x) => {
const obj = {
id: x.id,
species: x.recordedSpecies.species,
location: x.site.location,
binary_presence: x.binary_presence,
country: x.site.country,
year_start: x.year_start,
is_adult: !!x.adult_data,
is_larval: !!x.larval_data,
season_val: x.season_calc || x.season_given || '',
insecticide: x.insecticide_resistance_data,
control: x.sample?.control?.toString() || '',
abundance_data: x.abundance_data,
bio_data: x.bio_data,
display_name: x.recordedSpecies?.display_name,
category: x.recordedSpecies?.category,
color: x.recordedSpecies?.color,
//has_bionomics: x.bio_data,
};

// extend to other relations. This contradicts strict typing requirements but it
// was necessary so that we allow inclusion of related fields dynamically
if (!minimalFields) {
const extendedObject = selectAllFields(x, obj);
Object.assign(obj, extendedObject);
}
return obj;
});
const returnItems: OccurrenceReturn[] = mapOccurrencesToReturnItems(
items,
this.occurrenceService,
minimalFields,
);
return Object.assign(new PaginatedOccurrenceData(), {
items: returnItems,
total,
Expand Down
Loading
Loading