Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
82870d0
Adds support for full service url matching of saved systems
IainSAP Mar 29, 2026
b74b033
Adds RecursiceHierarchy data download
IainSAP Apr 2, 2026
062a7b9
Merge remote-tracking branch 'origin/main' into 250326
IainSAP Apr 2, 2026
5f96406
Adds `isActiveEntity` default true. Dont show hier. entities.
IainSAP Apr 3, 2026
5acff5c
Merge remote-tracking branch 'origin/main' into 250326
IainSAP Apr 3, 2026
6e997be
Fix props with same name wrong output file. Use full path
IainSAP Apr 10, 2026
2c89c62
Adds top level queries for node id generation
IainSAP Apr 10, 2026
068085d
Merge remote-tracking branch 'origin/main' into 250326
IainSAP Apr 10, 2026
1d6793a
Merge branch 'main' into feat/downloader/adds_rec_hierarchy
IainSAP Apr 13, 2026
20d7d32
Add tests to cover. Remove additional herarchy queries
IainSAP Apr 13, 2026
76ffa29
Merge remote-tracking branch 'origin/feat/downloader/adds_rec_hierarc…
IainSAP Apr 13, 2026
faf1011
Linting auto fix commit
github-actions[bot] Apr 13, 2026
8e7877a
Merge branch 'main' into feat/downloader/adds_rec_hierarchy
IainSAP Apr 13, 2026
fdc992e
Fix sonar issues
IainSAP Apr 13, 2026
b4585f3
update draft prop test
IainSAP Apr 13, 2026
5978918
Merge remote-tracking branch 'origin/feat/downloader/adds_rec_hierarc…
IainSAP Apr 13, 2026
998e793
Remove commented code
IainSAP Apr 13, 2026
dffbbf2
Adds cset
IainSAP Apr 13, 2026
5310ad8
Remove show output channel for now (waiting for msg config support)
IainSAP Apr 14, 2026
32d6ed2
Merge remote-tracking branch 'origin/main' into 250326
IainSAP Apr 14, 2026
46a256a
Update package.json
IainSAP Apr 14, 2026
c2b45ef
Merge branch 'main' into feat/downloader/adds_rec_hierarchy
IainSAP Apr 14, 2026
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
5 changes: 5 additions & 0 deletions .changeset/tidy-things-hope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sap-ux/generator-odata-downloader': minor
---

Adds recurisive hierarchy support
14 changes: 7 additions & 7 deletions packages/generator-odata-downloader/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@
"prebuilds",
"yeoman.png"
],
"dependencies": {},
"devDependencies": {
"@sap-devx/yeoman-ui-types": "1.23.0",
"devDependencies": {},
"dependencies": {
"@sap-devx/yeoman-ui-types": "1.22.0",
"@sap-ux/annotation-converter": "0.10.21",
"@sap-ux/axios-extension": "workspace:*",
"@sap-ux/btp-utils": "workspace:*",
Expand All @@ -59,12 +59,12 @@
"@sap-ux/vocabularies-types": "0.15.0",
"@sap/ux-specification": "1.144.0",
"@types/inquirer": "8.2.6",
"@types/yeoman-generator": "5.2.14",
"@types/yeoman-generator": "5.2.11",
"@vscode-logging/logger": "2.0.8",
"deepmerge": "4.3.1",
"i18next": "25.10.10",
"i18next": "25.8.18",
"inquirer": "8.2.7",
"odata-query": "8.0.7",
"odata-query": "8.0.5",
"os-name": "4.0.1",
"prettify-xml": "1.2.0",
"rimraf": "6.1.3",
Expand All @@ -73,4 +73,4 @@
"engines": {
"node": ">=20.x"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { initI18nODataDownloadGenerator, t } from '../utils/i18n';
import type { EntitySetsFlat } from './odata-query';
import { getODataDownloaderPrompts, promptNames } from './prompts/prompts';
import { type ReferencedEntities } from './types';
import { createEntitySetData } from './utils';
import { buildReferentialConstraintFileContent, createEntitySetData } from './utils';
import { getValueHelpSelectionPrompt } from './prompts/value-help-prompts';
import type { MockserverConfig, MockserverService } from '@sap-ux/ui5-config';
import {
Expand Down Expand Up @@ -133,6 +133,13 @@ export class ODataDownloadGenerator extends Generator {
this.prompts.setCallback(fn);
}
};

if (typeof opts.appWizard?.setHeaderTitle === 'function') {
opts.appWizard.setHeaderTitle(
t('texts.generatorTitle'),
`${this.rootGeneratorName()}@${this.rootGeneratorVersion()}`
);
}
}

/**
Expand Down Expand Up @@ -226,7 +233,8 @@ export class ODataDownloadGenerator extends Generator {
const entityFileData = createEntitySetData(
this.state.entityOData,
this.state.entityPropertyToEntitySet,
this.state.appEntities.listEntity.entitySetName
this.state.appEntities.listEntity.entitySetName,
this.state.appEntities.hierarchyEntities
);
ODataDownloadGenerator.logger.info(
t('info.entityFilesToBeGenerated', { entities: Object.keys(entityFileData).join(', ') })
Expand All @@ -238,6 +246,31 @@ export class ODataDownloadGenerator extends Generator {
// Writes relative to destination root path
this.writeDestinationJSON(join(this.state.mockDataRootPath!, `${entityName}.json`), entityData);
});

// Write mock server .js constraint files for hierarchy entities whose parent nav prop
// has no referentialConstraint in metadata — only for entity sets actually written,
// and only if the .js file does not already exist
this.state.appEntities.hierarchyEntities
?.filter(
(h) =>
h.missingReferentialConstraints && dataFiles.some(([name]) => name === h.entitySetName)
)
?.forEach((h) => {
const jsFilePath = join(this.state.mockDataRootPath!, `${h.entitySetName}.js`);
if (!this.fs.exists(jsFilePath)) {
const { navPropName, constraints } = h.missingReferentialConstraints!;
const content = buildReferentialConstraintFileContent(navPropName, constraints);
this.writeDestination(jsFilePath, content);
ODataDownloadGenerator.logger.info(
`Written referential constraint file: ${h.entitySetName}.js`
);
} else {
ODataDownloadGenerator.logger.debug(
`Skipping referential constraint file for '${h.entitySetName}' — already exists`
);
}
});

// eslint-disable-next-line @typescript-eslint/no-floating-promises
TelemetryHelper.sendTelemetry('ODATA_DOWNLOADER_WRITE_DATA_FILES_END', {
'writeFileDuration': `${Date.now() - writeStartTime} ms`,
Expand Down
190 changes: 180 additions & 10 deletions packages/generator-odata-downloader/src/data-download/odata-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ import buildQuery, { type Filter } from 'odata-query';
import { t } from '../utils/i18n';
import { ODataDownloadGenerator } from './odata-download-generator';
import { type SelectedEntityAnswer } from './prompts/prompts';
import type { ReferencedEntities } from './types';
import type { ReferencedEntities, HierarchyEntity } from './types';

export type EntitySetsFlat = { [entityPath: string]: string };
type ExpandTree = { expand?: Record<string, ExpandTree> };

/** Default number of hierarchy levels to fetch in a descendants query. */
const defaultHierarchyLevels = 3;

/**
* Builds the expands object used to create an odata query.
*
Expand Down Expand Up @@ -49,12 +52,14 @@ export function getExpands(entityPaths: { entityPath: string; entitySetName: str
* @param listEntity - The list entity to query from
* @param selectedEntities - The selected entities to include in the query
* @param top - The maximum number of records to return
* @param hierarchyEntity
* @returns The generated query string
*/
export function createQueryFromEntities(
listEntity: ReferencedEntities['listEntity'],
selectedEntities: SelectedEntityAnswer[],
top = 1
top = 1,
hierarchyEntity?: HierarchyEntity
): { query: string } {
const selectedPaths = selectedEntities?.map((entity) => {
return { entityPath: entity.fullPath, entitySetName: entity.entity.entitySetName };
Expand All @@ -74,13 +79,62 @@ export function createQueryFromEntities(
ODataDownloadGenerator.logger.info(t('info.entityFilesToBeGenerated', { entities: entitySetNames.join(', ') }));

const mainEntity = listEntity;

// Hierarchy entities use a descendants query instead of top/filter
if (hierarchyEntity) {
// Build filter from semantic key values
const filterParts: string[] = [];
mainEntity.semanticKeys.forEach((key) => {
if (key.value !== undefined && key.value !== '') {
if (key.type === 'Edm.Boolean') {
filterParts.push(`${key.name} eq ${key.value}`);
} else {
const values = String(key.value).split(',');
const isGuid = ['Edm.UUID', 'Edm.Guid'].includes(key.type);
const wrap = (v: string): string => (isGuid ? v.trim() : `'${v.trim()}'`);
if (values.length === 1) {
filterParts.push(`${key.name} eq ${wrap(values[0])}`);
} else {
const orParts = values.map((v) => `${key.name} eq ${wrap(v)}`);
filterParts.push(`(${orParts.join(' or ')})`);
}
}
}
});
const filterParam = filterParts.length > 0 ? `,filter(${filterParts.join(' and ')})` : '';

const hierarchyArgs = `$root/${mainEntity.entitySetName},${hierarchyEntity.qualifier},${hierarchyEntity.nodeProperty}`;
const topLevelsPart = `com.sap.vocabularies.Hierarchy.v1.TopLevels(HierarchyNodes=$root/${mainEntity.entitySetName},HierarchyQualifier='${hierarchyEntity.qualifier}',NodeProperty='${hierarchyEntity.nodeProperty}',Levels=${defaultHierarchyLevels})`;

// Draft-enabled hierarchies require an ancestors() wrapper to scope to active entities
const applyPart = hierarchyEntity.isDraft
? `$apply=ancestors(${hierarchyArgs}${filterParam},keep start)/${topLevelsPart}`
: `$apply=${topLevelsPart}`;

const expandQuery = entitiesToExpand ? buildQuery(entitiesToExpand) : '';
const expandPart = expandQuery ? `&${expandQuery.substring(1)}` : '';
const query = `${mainEntity.entitySetName}?${applyPart}${expandPart}`;
ODataDownloadGenerator.logger.debug(`Query for odata: ${query}`);
return { query };
}

const mainEntityFilters: Filter<string>[] = [];
mainEntity.semanticKeys.forEach((key) => {
// Process ranges and/or comma seperated values
if (key.value) {
if (key.type === 'Edm.String') {
if (key.value !== undefined && key.value !== '') {
if (['Edm.UUID', 'Edm.Guid'].includes(key.type)) {
const filterParts = String(key.value).split(',');
const filters: Filter<string>[] = filterParts.map((part) => ({
[key.name]: { type: 'guid' as const, value: part.trim() }
}));
mainEntityFilters.push(filters.length === 1 ? filters[0] : { or: filters });
} else if (key.type === 'Edm.Boolean') {
mainEntityFilters.push({
[key.name]: String(key.value) === 'true'
});
} else if (key.type === 'Edm.String') {
// Create the range and set values
const filterParts = key.value.split(',');
const filterParts = String(key.value).split(',');
const filters: Filter<string>[] = [];
filterParts.forEach((filterPart) => {
const filterRangeParts = filterPart.trim().split('-');
Expand Down Expand Up @@ -137,26 +191,142 @@ export function createQueryFromEntities(
ODataDownloadGenerator.logger.debug(`Query for odata: ${query}`);
return { query };
}
/**
* Builds a nav-key-rooted TopLevels query for a nav-prop that has a hierarchy annotation.
* e.g. PPS_PurchaseOrder(PurchaseOrder='4500003676',DraftUUID=...,IsActiveEntity=true)/_PurchaseOrderItem
* ?$apply=com.sap.vocabularies.Hierarchy.v1.TopLevels(HierarchyNodes=$root/...,...)
*
* @param listEntity - The parent/list entity (provides entity set name and key values)
* @param navPropName - The navigation property name connecting parent to child
* @param hierarchyEntity - The hierarchy descriptor for the nav-prop entity
* @returns The query string, or undefined if no semantic key values are available
*/
export function buildNavPropHierarchyQuery(
listEntity: ReferencedEntities['listEntity'],
navPropName: string,
hierarchyEntity: HierarchyEntity
): string | undefined {
const draftKeyNames = new Set(['DraftUUID', 'IsActiveEntity']);

// Build key segment: use keyName (actual entity key) if set, else name (semantic key = actual key)
const keyParts: string[] = [];
for (const key of listEntity.semanticKeys) {
if (key.value === undefined || key.value === '' || draftKeyNames.has(key.name)) {
continue;
}
const actualKeyName = key.keyName ?? key.name;
const firstValue = String(key.value).split(',')[0].trim();
const isGuid = ['Edm.UUID', 'Edm.Guid'].includes(key.type);
const isBoolean = key.type === 'Edm.Boolean';
keyParts.push(`${actualKeyName}=${isGuid || isBoolean ? firstValue : `'${firstValue}'`}`);
}

if (keyParts.length === 0) {
ODataDownloadGenerator.logger.debug(
`buildNavPropHierarchyQuery: no key values available for '${listEntity.entitySetName}', skipping nav-prop hierarchy query for '${navPropName}'`
);
return undefined;
}

// Draft entities: append fixed active-read values for whichever draft keys are present
const draftFixedValues: Record<string, string> = {
DraftUUID: '00000000-0000-0000-0000-000000000000',
IsActiveEntity: 'true'
};
listEntity.entityType?.keys
?.filter((k) => k.name in draftFixedValues)
.forEach((k) => keyParts.push(`${k.name}=${draftFixedValues[k.name]}`));

ODataDownloadGenerator.logger.debug(
`buildNavPropHierarchyQuery: key segment for '${listEntity.entitySetName}': (${keyParts.join(',')})`
);

const keySegment = `${listEntity.entitySetName}(${keyParts.join(',')})`;
const navPath = `${keySegment}/${navPropName}`;
const hierarchyNodesRef = `$root/${navPath}`;

const topLevelsPart = `com.sap.vocabularies.Hierarchy.v1.TopLevels(HierarchyNodes=${hierarchyNodesRef},HierarchyQualifier='${hierarchyEntity.qualifier}',NodeProperty='${hierarchyEntity.nodeProperty}',Levels=${defaultHierarchyLevels})`;
const query = `${navPath}?$apply=${topLevelsPart}`;
ODataDownloadGenerator.logger.debug(`Nav-prop hierarchy query: ${query}`);
return query;
}

/**
* Builds an odata query and fetches the data from a backend.
*
* @param listEntity - The list entity to query from
* @param odataService - The OData service to use for fetching
* @param selectedEntities - The selected entities to include in the query
* @param top - The maximum number of records to return
* @param hierarchyEntity - Hierarchy entity when the list entity itself is a hierarchy root
* @param navPropHierarchyEntities - Hierarchy entities for selected nav-props (not the list entity)
* @returns The fetched OData result
*/
export async function fetchData(
listEntity: ReferencedEntities['listEntity'],
odataService: ODataService,
selectedEntities: SelectedEntityAnswer[],
top?: number
top?: number,
hierarchyEntity?: HierarchyEntity,
navPropHierarchyEntities: HierarchyEntity[] = []
): Promise<{ odataResult: { entityData?: []; error?: string } }> {
const query = createQueryFromEntities(listEntity, selectedEntities, top);
const query = createQueryFromEntities(listEntity, selectedEntities, top, hierarchyEntity);
const odataResult = await executeQuery(odataService, query.query);
return {
odataResult
};

// Issue additional nav-key-rooted TopLevels queries for nav-prop hierarchy entities
for (const navHierarchy of navPropHierarchyEntities) {
const matchingEntity = selectedEntities.find((s) => s.entity.entitySetName === navHierarchy.entitySetName);
if (!matchingEntity) {
ODataDownloadGenerator.logger.debug(
`fetchData: no selected entity matched hierarchy entity '${navHierarchy.entitySetName}', skipping`
);
continue;
}
const navQuery = buildNavPropHierarchyQuery(listEntity, matchingEntity.entity.entityPath, navHierarchy);
if (!navQuery) {
ODataDownloadGenerator.logger.debug(
`fetchData: could not build nav-prop hierarchy query for '${navHierarchy.entitySetName}', skipping`
);
continue;
}
const navResult = await executeQuery(odataService, navQuery);
if (!navResult.entityData || !odataResult.entityData) {
ODataDownloadGenerator.logger.debug(
`fetchData: no data returned for nav-prop hierarchy query '${navHierarchy.entitySetName}', skipping merge`
);
continue;
}

ODataDownloadGenerator.logger.debug(
`fetchData: merging ${navResult.entityData.length} hierarchy records into '${matchingEntity.entity.entityPath}' expanded items`
);

// Patch hierarchy properties in-place onto matching expanded nav-prop records
const navPropName = matchingEntity.entity.entityPath;
const hierPropName = navHierarchy.nodeProperty.split('/')[0];
const navResultData = navResult.entityData as Record<string, unknown>[];
let patchCount = 0;

for (const rootRecord of odataResult.entityData as Record<string, unknown>[]) {
const expandedItems = rootRecord[navPropName];
if (!Array.isArray(expandedItems)) {
continue;
}
for (const item of expandedItems as Record<string, unknown>[]) {
const match = navResultData.find((h) => navHierarchy.entityTypeKeys.every((k) => h[k] === item[k]));
if (match) {
item[hierPropName] = match[hierPropName];
patchCount++;
}
}
}

ODataDownloadGenerator.logger.debug(
`fetchData: patched '${hierPropName}' onto ${patchCount} expanded '${navPropName}' records`
);
}

return { odataResult };
}

/**
Expand Down
Loading
Loading