Skip to content
Merged
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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,30 @@

This is a plugin for the [CAP](https://cap.cloud.sap/) framework that allows you to import data from spreadsheets into your CAP project.

## Enable Import for Selected Entities

By default, the importer keeps the existing behavior and allows uploads for all entities. To limit uploads to selected entities, annotate the allowed service entities with `@spreadsheetimporter.enabled` in your CDS model:

```cds
using my.bookshop as my from '../db/schema';
using from 'cds-spreadsheetimporter-plugin';

service CatalogService {
@spreadsheetimporter.enabled
entity Books as projection on my.Books;

entity Authors as projection on my.Authors;
}
```

When at least one entity is annotated, the importer rejects uploads for every other entity with `403 SPREADSHEET_IMPORT_ENTITY_NOT_ENABLED`. Annotating a service projection also enables uploads for its underlying persistence entity, so existing URLs such as `/odata/v4/importer/Spreadsheet(entity='my.bookshop.Books')/content` keep working. Short entity names resolved by CAP, such as `Books`, are supported as well.

Use this annotation only to control which entities the importer may target. For user or role-based authorization, continue to use CAP authorization annotations such as `@requires` or `@restrict` on the importer service or your application services. For example:

```cds
annotate ImporterService with @requires: 'Admin';
```

## Release Process

This project uses [release-it](https://github.com/release-it/release-it) to automate version management and package publishing. The release workflow is configured through GitHub Actions and can be triggered in two ways:
Expand Down
305 changes: 232 additions & 73 deletions importer-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,179 @@ const cds = global.cds || require('@sap/cds');
const XLSX = require('xlsx');
const SheetHandler = require('./utils/SheetHandler');
const Parser = require('./utils/Parser');
const { PassThrough } = require('stream');

module.exports = class ImporterService extends cds.ApplicationService {
const IMPORTER_ENABLED_ANNOTATION = '@spreadsheetimporter.enabled';
const ENTITY_NOT_ENABLED_CODE = 'SPREADSHEET_IMPORT_ENTITY_NOT_ENABLED';

function getDefinitions() {
return cds.model?.definitions || cds.entities?.() || {};
}

function getEntityLookup() {
return cds.entities?.() || {};
}

function findEntity(entityName, definitions, entityLookup) {
if (definitions[entityName]) {
return {
entity: definitions[entityName],
entityName,
};
}

const entity = entityLookup[entityName];
if (!entity) return undefined;

return {
entity,
entityName: entity.name || entityName,
};
}

function getProjectionSourceName(entity) {
const ref =
entity?.query?.SELECT?.from?.ref || entity?.projection?.from?.ref || [];
const sourceName = ref
.map((part) => (typeof part === 'string' ? part : part.id))
.filter(Boolean)
.join('.');

return sourceName || undefined;
}

function resolvePersistenceName(entity, definitions, seen = new Set(), fallbackName) {
if (!entity) return undefined;

const entityName = entity.name || fallbackName;
if (entityName) {
if (seen.has(entityName)) return entityName;
seen.add(entityName);
}

const sourceName = getProjectionSourceName(entity);
if (!sourceName || sourceName === entityName) return entityName;

return (
resolvePersistenceName(definitions[sourceName], definitions, seen, sourceName) ||
sourceName
);
}

function getEnabledImportEntities(definitions = getDefinitions()) {
const enabledEntities = new Set();

for (const [entityName, entity] of Object.entries(definitions)) {
if (entity?.kind !== 'entity' || entity[IMPORTER_ENABLED_ANNOTATION] !== true) {
continue;
}

enabledEntities.add(entity.name || entityName);

const persistenceName = resolvePersistenceName(
entity,
definitions,
new Set(),
entityName
);
if (persistenceName) enabledEntities.add(persistenceName);
}

return enabledEntities;
}

function resolveImportEntity(
entityName,
definitions = getDefinitions(),
entityLookup = getEntityLookup()
) {
const found = findEntity(entityName, definitions, entityLookup);
if (!found) return undefined;

const requested = found.entity;
const requestedName = found.entityName;
const persistenceName = resolvePersistenceName(
requested,
definitions,
new Set(),
requestedName
);
const target = definitions[persistenceName] || entityLookup[persistenceName] || requested;

return {
requested,
requestedName,
target,
targetName: target.name || persistenceName || requestedName,
};
}

function isImportEnabledForEntity(
entityName,
definitions = getDefinitions(),
entityLookup = getEntityLookup()
) {
const enabledEntities = getEnabledImportEntities(definitions);
if (enabledEntities.size === 0) return true;

const resolvedEntity = resolveImportEntity(
entityName,
definitions,
entityLookup
);
if (!resolvedEntity) return false;

return (
enabledEntities.has(entityName) ||
enabledEntities.has(resolvedEntity.requestedName) ||
enabledEntities.has(resolvedEntity.targetName)
);
}

class ImporterService extends cds.ApplicationService {
init() {
this.on('UPDATE', 'Spreadsheet', async (req) => {
try {
console.log(
'Spreadsheet importer received request with content type:',
req.headers && req.headers['content-type']
);
console.log('Entity parameter:', req.params[0].entity);
console.log('Request data structure:', Object.keys(req.data || {}));
console.log(
'Spreadsheet importer received request with content type:',
req.headers && req.headers['content-type']
);

const entity = cds.entities()[req.params[0].entity];
if (!entity) {
req.error(400, `Entity '${req.params[0].entity}' not found`);
return;
}
const requestedEntityName = req.params?.[0]?.entity;
console.log('Entity parameter:', requestedEntityName);
console.log('Request data structure:', Object.keys(req.data || {}));

// Handle file content using a streaming approach for large files
const chunks = [];
const definitions = getDefinitions();
const entityLookup = getEntityLookup();
const resolvedEntity = resolveImportEntity(
requestedEntityName,
definitions,
entityLookup
);
if (!resolvedEntity) {
return req.reject(400, `Entity '${requestedEntityName}' not found`);
}

if (
!isImportEnabledForEntity(
requestedEntityName,
definitions,
entityLookup
)
) {
return req.reject({
status: 403,
code: ENTITY_NOT_ENABLED_CODE,
message: `Spreadsheet import is not enabled for entity '${requestedEntityName}'`,
});
}

// Check if we have access to the content as a stream
console.log('Processing content as a stream');
// Handle file content using a streaming approach for large files
const chunks = [];

// Collect all chunks before processing
// Check if we have access to the content as a stream
console.log('Processing content as a stream');

// Collect all chunks before processing
try {
await new Promise((resolve, reject) => {
req.data.content.on('data', (chunk) => {
console.log(`Received chunk of size: ${chunk.length} bytes`);
Expand All @@ -44,74 +191,86 @@ module.exports = class ImporterService extends cds.ApplicationService {
reject(err);
});
});
} catch (error) {
console.error('Spreadsheet import error:', error);
return req.reject(500, `Failed to process spreadsheet: ${error.message}`);
}

// Once we have all chunks, process the file
const totalBuffer = Buffer.concat(chunks);
console.log(
`Processing complete file of size: ${totalBuffer.length} bytes`
);
// Once we have all chunks, process the file
const totalBuffer = Buffer.concat(chunks);
console.log(
`Processing complete file of size: ${totalBuffer.length} bytes`
);

try {
const spreadSheet = XLSX.read(totalBuffer, {
type: 'buffer',
cellNF: true,
cellDates: true,
cellText: true,
cellFormula: true,
});
try {
const spreadSheet = XLSX.read(totalBuffer, {
type: 'buffer',
cellNF: true,
cellDates: true,
cellText: true,
cellFormula: true,
});

let spreadsheetSheetsData = [];
let columnNames = [];
let spreadsheetSheetsData = [];
let columnNames = [];

console.log(
`Workbook contains ${spreadSheet.SheetNames.length} sheets`
);

// Loop over the sheet names in the workbook
for (const sheetName of Object.keys(spreadSheet.Sheets)) {
console.log(`Processing sheet: ${sheetName}`);
let currSheetData = SheetHandler.sheet_to_json(
spreadSheet.Sheets[sheetName]
);
console.log(
`Workbook contains ${spreadSheet.SheetNames.length} sheets`
`Sheet ${sheetName} has ${currSheetData.length} rows of data`
);

// Loop over the sheet names in the workbook
for (const sheetName of Object.keys(spreadSheet.Sheets)) {
console.log(`Processing sheet: ${sheetName}`);
let currSheetData = SheetHandler.sheet_to_json(
spreadSheet.Sheets[sheetName]
);
console.log(
`Sheet ${sheetName} has ${currSheetData.length} rows of data`
);

for (const dataVal of currSheetData) {
Object.keys(dataVal).forEach((key) => {
dataVal[key].sheetName = sheetName;
});
}

spreadsheetSheetsData = spreadsheetSheetsData.concat(currSheetData);
columnNames = columnNames.concat(
XLSX.utils.sheet_to_json(spreadSheet.Sheets[sheetName], {
header: 1,
})[0]
);
for (const dataVal of currSheetData) {
Object.keys(dataVal).forEach((key) => {
dataVal[key].sheetName = sheetName;
});
}

console.log(
`Total data rows to process: ${spreadsheetSheetsData.length}`
spreadsheetSheetsData = spreadsheetSheetsData.concat(currSheetData);
columnNames = columnNames.concat(
XLSX.utils.sheet_to_json(spreadSheet.Sheets[sheetName], {
header: 1,
})[0]
);
const data = Parser.parseSpreadsheetData(
spreadsheetSheetsData,
entity.elements
);
console.log(`Inserting ${data.length} rows into ${entity.name}`);

await cds.db.run(INSERT(data).into(entity.name));
console.log('Import completed successfully');
} catch (xlsxError) {
console.error('Error processing Excel file:', xlsxError);
req.error(400, `Failed to parse spreadsheet: ${xlsxError.message}`);
return;
}

console.log(
`Total data rows to process: ${spreadsheetSheetsData.length}`
);
const data = Parser.parseSpreadsheetData(
spreadsheetSheetsData,
resolvedEntity.requested.elements || resolvedEntity.target.elements
);
console.log(
`Inserting ${data.length} rows into ${resolvedEntity.targetName}`
);

await cds.db.run(INSERT(data).into(resolvedEntity.targetName));
console.log('Import completed successfully');
} catch (error) {
console.error('Spreadsheet import error:', error);
req.error(500, `Failed to process spreadsheet: ${error.message}`);
console.error('Error processing Excel file:', error);
return req.reject(400, `Failed to parse spreadsheet: ${error.message}`);
}
});
return super.init();
}
}

module.exports = ImporterService;
module.exports._private = {
ENTITY_NOT_ENABLED_CODE,
IMPORTER_ENABLED_ANNOTATION,
findEntity,
getEnabledImportEntities,
isImportEnabledForEntity,
resolveImportEntity,
resolvePersistenceName,
};
Loading
Loading