diff --git a/src/models/data-fabric/entities.constants.ts b/src/models/data-fabric/entities.constants.ts index 2a44cc41b1..af87e76f4e 100644 --- a/src/models/data-fabric/entities.constants.ts +++ b/src/models/data-fabric/entities.constants.ts @@ -1,5 +1,6 @@ -import { EntityFieldDataType, EntityType, FieldDisplayType } from "./entities.types"; +import { EntityClass, EntityFieldDataType, EntityType, FieldDisplayType } from "./entities.types"; import { + EntityClassId, EntitySchemaFieldMapping, SqlFieldType, EntityFieldConstraint, @@ -162,3 +163,13 @@ export const EntityFieldTypeMap: Record = { [SqlFieldType.MULTILINE]: EntityFieldDataType.MULTILINE_TEXT, [SqlFieldType.MULTILINE_MAX]: EntityFieldDataType.MULTILINE_MAX, }; + +/** + * Maps the user-facing {@link EntityClass} to the numeric `entityClassId` the v3 + * create endpoint expects. Only the two user-creatable classes are listed; any other + * value is rejected by `create()`. + */ +export const EntityClassToIdMap: Partial> = { + [EntityClass.Native]: EntityClassId.Native, + [EntityClass.Federated]: EntityClassId.Federated, +}; diff --git a/src/models/data-fabric/entities.internal-types.ts b/src/models/data-fabric/entities.internal-types.ts index f87bf6f785..ba971835c7 100644 --- a/src/models/data-fabric/entities.internal-types.ts +++ b/src/models/data-fabric/entities.internal-types.ts @@ -1,4 +1,16 @@ -import { EntityType, FieldDisplayType, EntityRecord, ReferenceType, SqlType } from './entities.types'; +import { EntityType, FieldDisplayType, EntityRecord, ReferenceType, SqlType, EntityUpdateByIdOptions } from './entities.types'; + +/** + * Numeric v3 entity-class discriminator sent on create as + * `entityDefinition.entityClassId`. Wire-format counterpart of {@link EntityClass}; + * internal — consumers pass {@link EntityClass} and the SDK translates via `EntityClassToIdMap`. + */ +export enum EntityClassId { + /** Native entity — data fully stored and managed within UiPath */ + Native = 9, + /** Federated entity — unified read-only view across UiPath and external sources */ + Federated = 10, +} /** * Write-side payload shape for creating a new field in a schema upsert call. @@ -65,6 +77,25 @@ export interface EntityJoinPayload { on: { left: string; right: string }; } +/** Wire-ready Federated parts produced by `buildFederatedUpsertParts`. */ +export interface FederatedUpsertParts { + externalFields: Array>; + sourceJoinConditionDetails?: Array>; + entityClassId?: number; +} + +/** The Federated source/join delta fields of `EntityUpdateByIdOptions`. */ +export type FederatedUpdateDeltas = Pick< + EntityUpdateByIdOptions, + | 'addExternalSources' + | 'removeExternalSources' + | 'addFieldsToSource' + | 'removeFieldsFromSource' + | 'updateExternalFieldMapping' + | 'addSourceJoins' + | 'updateSourceJoin' +>; + /** * Names of the per-field SQL constraint properties (i.e. the contents of `sqlType` * excluding its `name`). Used internally to validate user-supplied constraints diff --git a/src/models/data-fabric/entities.models.ts b/src/models/data-fabric/entities.models.ts index a9545cce6b..ff3366f206 100644 --- a/src/models/data-fabric/entities.models.ts +++ b/src/models/data-fabric/entities.models.ts @@ -983,7 +983,7 @@ export interface EntityServiceModel { * @returns Promise resolving to the ID of the created entity * @example * ```typescript - * import { Entities } from '@uipath/uipath-typescript/entities'; + * import { Entities, EntityClass, DataDirectionType, EntityFieldDataType, JoinType } from '@uipath/uipath-typescript/entities'; * * const entities = new Entities(sdk); * @@ -1016,6 +1016,26 @@ export interface EntityServiceModel { * // referenceFolderKey omitted → SDK looks up the target at tenant scope * }, * ], { folderKey: "" }); + * + * // Federated entity — a read-only view over external and/or native sources. + * // Native columns stay empty ([]); the schema comes from `externalFields`. + * await entities.create("", [], { + * entityClass: EntityClass.Federated, + * externalFields: [{ + * externalConnectionDetail: { + * connectionId: "", connectorKey: "", connectorName: "", + * elementInstanceId: 0, folderKey: "", + * }, + * externalObjectDetail: { externalObjectName: "", primaryKey: "", isPrimarySource: true, method: "" }, + * fields: [{ + * field: { name: "", type: EntityFieldDataType.STRING }, + * externalFieldMappingDetail: { externalFieldName: "", directionType: DataDirectionType.ReadOnly }, + * }], + * }], + * // Multi-source: add more entries to `externalFields` and join them: + * // sourceJoinConditionDetails: [{ sourceObjectName: "", sourceJoinField: "", + * // joinType: JoinType.LeftJoin, relatedSourceObjectName: "", relatedSourceJoinField: "" }], + * }); * ``` * @experimental */ @@ -1045,12 +1065,20 @@ export interface EntityServiceModel { * metadata fields (`displayName`, `description`, `isRbacEnabled`). Each group is applied * only when the corresponding fields are provided. * + * For **Federated** entities, pass source/join deltas instead: `addExternalSources`, + * `removeExternalSources` (also removes that source's joins), `addFieldsToSource`, + * `removeFieldsFromSource`, `updateExternalFieldMapping`, `addSourceJoins`, and + * `updateSourceJoin`. `addFieldsToSource` maps a field that already exists on the source + * (a native entity's column or a connector field); it does not create the underlying field. + * * @param id - UUID of the entity to update - * @param options - Changes to apply ({@link EntityUpdateByIdOptions}). At least one of `addFields`, `removeFields`, `updateFields`, `displayName`, `description`, or `isRbacEnabled` must be provided — calling with no options, `{}`, or only `folderKey` throws a `ValidationError`. Field names passed in `addFields[].name` and `removeFields[].name` must be camelCase — start with a letter, letters and numbers only; the Data Fabric backend rejects underscores in field names. The `folderKey` property is **experimental**. + * @param options - Changes to apply ({@link EntityUpdateByIdOptions}). At least one of `addFields`, `removeFields`, `updateFields`, `displayName`, `description`, `isRbacEnabled`, or a federated source/join delta (`addExternalSources`, `removeExternalSources`, `addFieldsToSource`, `removeFieldsFromSource`, `updateExternalFieldMapping`, `addSourceJoins`, `updateSourceJoin`) must be provided — calling with no options, `{}`, or only `folderKey` throws a `ValidationError`. Field names passed in `addFields[].name` and `removeFields[].name` must be camelCase — start with a letter, letters and numbers only; the Data Fabric backend rejects underscores in field names. The `folderKey` property is **experimental**. * @returns Promise resolving when the update is complete * * @example * ```typescript + * import { Entities, EntityFieldDataType, DataDirectionType, JoinType } from '@uipath/uipath-typescript/entities'; + * * // Schema-only: add a field and remove another * await entities.updateById(, { * addFields: [{ name: "notes", type: EntityFieldDataType.MULTILINE_TEXT }], @@ -1085,6 +1113,29 @@ export interface EntityServiceModel { * folderKey: "", * addFields: [{ name: "notes", type: EntityFieldDataType.MULTILINE_TEXT }], * }); + * + * // Federated: add a source joined to the existing graph + * await entities.updateById(, { + * addExternalSources: [{ + * externalConnectionDetail: { connectionId: "", elementInstanceId: 0, connectorKey: "", connectorName: "" }, + * externalObjectDetail: { externalObjectName: "", primaryKey: "", method: "" }, + * fields: [{ field: { name: "", type: EntityFieldDataType.STRING }, externalFieldMappingDetail: { externalFieldName: "", directionType: DataDirectionType.ReadOnly } }], + * }], + * addSourceJoins: [{ sourceObjectName: "", sourceJoinField: "", relatedSourceObjectName: "", relatedSourceJoinField: "", joinType: JoinType.LeftJoin }], + * }); + * + * // Federated: add a field to an existing source (maps a field that already exists on it) + * await entities.updateById(, { + * addFieldsToSource: [{ sourceObjectName: "", fields: [{ field: { name: "", type: EntityFieldDataType.STRING }, externalFieldMappingDetail: { externalFieldName: "", directionType: DataDirectionType.ReadOnly } }] }], + * }); + * + * // Federated: change an existing join in place + * await entities.updateById(, { + * updateSourceJoin: [{ sourceObjectName: "", relatedSourceObjectName: "", sourceJoinField: "" }], + * }); + * + * // Federated: remove a source (its joins are removed automatically) + * await entities.updateById(, { removeExternalSources: [""] }); * ``` * @experimental */ @@ -1385,7 +1436,7 @@ export interface EntityMethods { /** * Updates this entity — schema and/or metadata. * - * @param options - Changes to apply ({@link EntityUpdateByIdOptions}). At least one of `addFields`, `removeFields`, `updateFields`, `displayName`, `description`, or `isRbacEnabled` must be provided — calling with no options, `{}`, or only `folderKey` throws a `ValidationError`. Field names passed in `addFields[].name` and `removeFields[].name` must be camelCase — start with a letter, letters and numbers only; the Data Fabric backend rejects underscores in field names. The `folderKey` property is **experimental**. + * @param options - Changes to apply ({@link EntityUpdateByIdOptions}). At least one of `addFields`, `removeFields`, `updateFields`, `displayName`, `description`, `isRbacEnabled`, or a federated source/join delta (`addExternalSources`, `removeExternalSources`, `addFieldsToSource`, `removeFieldsFromSource`, `updateExternalFieldMapping`, `addSourceJoins`, `updateSourceJoin`) must be provided — calling with no options, `{}`, or only `folderKey` throws a `ValidationError`. Field names passed in `addFields[].name` and `removeFields[].name` must be camelCase — start with a letter, letters and numbers only; the Data Fabric backend rejects underscores in field names. The `folderKey` property is **experimental**. * @returns Promise resolving when the update is complete * @example * ```typescript diff --git a/src/models/data-fabric/entities.types.ts b/src/models/data-fabric/entities.types.ts index cb88b80937..7672fdb572 100644 --- a/src/models/data-fabric/entities.types.ts +++ b/src/models/data-fabric/entities.types.ts @@ -492,8 +492,29 @@ export interface EntityCreateOptions extends EntityFolderScopedOptions { * @experimental Analytics integration is in preview — the contract may change. */ isAnalyticsEnabled?: boolean; - /** External field source definitions (default: empty) */ - externalFields?: ExternalField[]; + /** + * Product class of the entity (default: `Native`). Set to `Federated` to create a + * federated entity — a read-only view over one or more external/native sources. + * A Federated entity requires at least one source in `externalFields`. + * + * @experimental + */ + entityClass?: EntityClass; + /** + * External source definitions — the connections, objects, and field mappings a + * Federated entity reads from. Required when `entityClass` is `Federated`; ignored + * for Native entities. + * + * @experimental + */ + externalFields?: EntityCreateExternalSource[]; + /** + * Cross-source joins for a multi-source Federated entity. Each entry joins two + * sources by object + field name; omit for a single-source Federated entity. + * + * @experimental + */ + sourceJoinConditionDetails?: SourceJoinConditionDetail[]; } /** @@ -532,6 +553,55 @@ export interface EntityUpdateByIdOptions extends EntityFolderScopedOptions { description?: string; /** Whether role-based access control is enabled for this entity */ isRbacEnabled?: boolean; + + // ── Federated source/join deltas ── + + /** External sources to add to a Federated entity (same shape as create's `externalFields`). @experimental */ + addExternalSources?: EntityCreateExternalSource[]; + /** External sources to remove, by `externalObjectName`. @experimental */ + removeExternalSources?: string[]; + /** Fields to add to an existing source, keyed by the source's `externalObjectName`. @experimental */ + addFieldsToSource?: EntityAddFieldsToSource[]; + /** Fields to remove from an existing source, keyed by the source's `externalObjectName`. @experimental */ + removeFieldsFromSource?: EntityRemoveFieldsFromSource[]; + /** Field-mapping updates (searchability/direction/sortable) on an existing external field. @experimental */ + updateExternalFieldMapping?: EntityUpdateExternalFieldMapping[]; + /** Cross-source joins to add (typically paired with `addExternalSources` — a new + * non-primary source must be connected to the graph by a join). @experimental */ + addSourceJoins?: SourceJoinConditionDetail[]; + /** Update an existing join in place — change its join fields and/or type. Identified by + * source + related object names. (There is no standalone remove-join: a join can't + * outlive its source, so `removeExternalSources` cascades its joins automatically.) @experimental */ + updateSourceJoin?: EntityUpdateSourceJoin[]; +} + +/** Fields to add to a Federated source, identified by the source object name. @experimental */ +export interface EntityAddFieldsToSource { + sourceObjectName: string; + fields: EntityCreateExternalField[]; +} + +/** Fields to remove from a Federated source, identified by the source object name. @experimental */ +export interface EntityRemoveFieldsFromSource { + sourceObjectName: string; + fieldNames: string[]; +} + +/** A mapping update for one external field on a Federated source. @experimental */ +export interface EntityUpdateExternalFieldMapping { + sourceObjectName: string; + fieldName: string; + mapping: Partial; +} + +/** Updates an existing Federated cross-source join in place, identified by the two object + * names. Only the supplied fields change; the others are kept. @experimental */ +export interface EntityUpdateSourceJoin { + sourceObjectName: string; + relatedSourceObjectName: string; + sourceJoinField?: string; + relatedSourceJoinField?: string; + joinType?: JoinType; } /** @@ -636,6 +706,18 @@ export enum EntityType { SystemEntity = "SystemEntity", } +/** + * Product classification of an entity. + */ +export enum EntityClass { + /** Native entity — data fully stored and managed within UiPath */ + Native = "Native", + /** Federated entity — unified read-only view across UiPath and external sources. */ + Federated = "Federated", + /** Case-family entity — read-only: returned by `getById`, not a valid value for `create`. */ + Case = "Case", +} + /** * Field type metadata */ @@ -667,7 +749,7 @@ export enum FieldDisplayType { } /** - * Data direction type for external fields + * Read/write direction for an external field. */ export enum DataDirectionType { ReadOnly = "ReadOnly", @@ -754,6 +836,8 @@ export interface ExternalObject { externalConnectionId: string; entityId?: string; isPrimarySource: boolean; + /** External access method for the object */ + method?: string; } /** @@ -769,6 +853,35 @@ export interface ExternalConnection { connectionName?: string; } +/** + * Operator-level searchability metadata. + * + * @experimental + */ +export interface SearchabilityOperator { + searchableOperators?: string[]; +} + +/** + * Named-search searchability metadata. + * + * @experimental + */ +export interface SearchabilityNamedSearch { + searchableNames?: string[]; +} + +/** + * Field searchability metadata. + * + * @experimental + */ +export interface Searchability { + searchable: boolean; + supportsOperators?: SearchabilityOperator; + supportsNamedSearch?: SearchabilityNamedSearch; +} + /** * External field mapping */ @@ -780,6 +893,12 @@ export interface ExternalFieldMapping { externalFieldType?: string; internalFieldId: string; directionType: DataDirectionType; + /** Field searchability metadata */ + searchability?: Searchability; + /** Whether this external field is required for read operations */ + isRequiredForRead?: boolean; + /** Whether this external field can be used for sorting */ + sortable?: boolean; } /** @@ -790,6 +909,18 @@ export interface ExternalField { externalFieldMappingDetail: ExternalFieldMapping; } +/** + * Native connection detail — set for a Native source referencing another UiPath entity + * (Federated entities with Native sources). When present, `externalConnectionDetail` may be empty. + * @experimental + */ +export interface NativeConnectionDetail { + /** Id of the referenced native UiPath entity (the source to read from) */ + entityId: string; + /** Folder that owns the referenced native entity */ + folderKey: string; +} + /** * External source fields */ @@ -797,6 +928,8 @@ export interface ExternalSourceFields { fields?: ExternalField[]; externalObjectDetail?: ExternalObject; externalConnectionDetail?: ExternalConnection; + /** Set for a Native source (referencing another UiPath entity); see {@link NativeConnectionDetail}. @experimental */ + nativeConnectionDetail?: NativeConnectionDetail; } /** @@ -805,12 +938,123 @@ export interface ExternalSourceFields { export interface SourceJoinCriteria { id: string; entityId: string; + /** Id of the source object on the owning side of the join */ + sourceObjectId?: string; joinFieldName?: string; joinType: JoinType; relatedSourceObjectId?: string; relatedSourceFieldName?: string; } +/** + * A join between two sources of a Federated entity, expressed by source object and field names. + * @experimental + */ +export interface SourceJoinConditionDetail { + /** Name of the object on the owning side of the join. */ + sourceObjectName: string; + /** Field on the source object used to match. */ + sourceJoinField: string; + /** Connection id of the source object (the external connection this object belongs to). */ + sourceObjectConnectionId?: string; + /** How records are matched across the two sources. */ + joinType: JoinType; + /** Name of the object on the related side of the join. */ + relatedSourceObjectName: string; + /** Field on the related source object used to match. */ + relatedSourceJoinField: string; + /** Connection id of the related source object. */ + relatedSourceObjectConnectionId?: string; +} + +/** + * Connection an external source reads from. + * @experimental + */ +export interface EntityCreateExternalConnection { + /** Integration Service connection id. */ + connectionId: string; + /** Element instance id of the connection. */ + elementInstanceId?: number; + /** Folder that owns the connection. */ + folderKey?: string; + /** Connector key (e.g. `uipath-salesforce`). */ + connectorKey: string; + /** Connector display name. */ + connectorName: string; + /** Connection display name. */ + connectionName?: string; +} + +/** + * External object (table) a source reads from. + * @experimental + */ +export interface EntityCreateExternalObject { + /** Name of the object on the external system (e.g. `Account`). */ + externalObjectName: string; + /** Display name of the external object. */ + externalObjectDisplayName?: string; + /** Primary key field on the external object. */ + primaryKey?: string; + /** Whether this is the primary source of the federated entity. */ + isPrimarySource?: boolean; + /** External access method for the object. */ + method?: string; +} + +/** + * Maps an external source field to its internal column. + * @experimental + */ +export interface EntityCreateExternalFieldMapping { + /** Name of the field on the external source. */ + externalFieldName: string; + /** Display name of the external source field. */ + externalFieldDisplayName?: string; + /** Type of the field on the external source. */ + externalFieldType?: string; + /** Read-only vs read/write direction for this field. */ + directionType: DataDirectionType; + /** Field searchability metadata. */ + searchability?: Searchability; + /** Whether this external field is required for read operations. */ + isRequiredForRead?: boolean; + /** Whether this external field can be used for sorting. */ + sortable?: boolean; +} + +/** + * A single field contributed by an external source on create: an internal column + * (defined exactly like a native field via {@link EntityCreateFieldOptions}) plus the + * mapping back to the external source field. + * @experimental + */ +export interface EntityCreateExternalField { + /** Internal column definition — same shape as a native create field. */ + field: EntityCreateFieldOptions; + /** Mapping from the external source field to this internal column. */ + externalFieldMappingDetail: EntityCreateExternalFieldMapping; +} + +/** + * One source of a Federated entity on create — the connection and object it reads + * from, and the fields it contributes. Supply `externalConnectionDetail` for a truly + * external source, or `nativeConnectionDetail` for a source backed by another UiPath + * entity. + * @experimental + */ +export interface EntityCreateExternalSource { + /** Fields this source contributes to the federated entity. */ + fields?: EntityCreateExternalField[]; + /** The external object (table) this source reads from. Required — identifies the source table; no server default. */ + externalObjectDetail: EntityCreateExternalObject; + /** Connection for a truly external source (Salesforce, SAP, ServiceNow, …). */ + externalConnectionDetail?: EntityCreateExternalConnection; + /** Connection for a Native source referencing another UiPath entity. */ + nativeConnectionDetail?: NativeConnectionDetail; +} + /** * Entity metadata returned by getById */ @@ -818,10 +1062,18 @@ export interface RawEntityGetResponse { name: string; displayName: string; entityType: EntityType; + /** Numeric entity type identifier */ + entityTypeId?: number; + /** Template discriminator — null for non-templated entities, set for templated ones */ + templateName?: string; + /** Product classification: Native, Federated, or Case. @experimental */ + entityClass?: EntityClass; description?: string; fields: FieldMetaData[]; folderId?: string; + /** External source definitions — present only on Federated entities. @experimental */ externalFields?: ExternalSourceFields[]; + /** Cross-source join criteria — present only on Federated entities. @experimental */ sourceJoinCriterias?: SourceJoinCriteria[]; recordCount?: number; storageSizeInMB?: number; diff --git a/src/services/data-fabric/choicesets.ts b/src/services/data-fabric/choicesets.ts index de5836349c..233f2975f9 100644 --- a/src/services/data-fabric/choicesets.ts +++ b/src/services/data-fabric/choicesets.ts @@ -136,9 +136,8 @@ export class ChoiceSetService extends BaseService implements ChoiceSetServiceMod @track('Choicesets.DeleteById') async deleteById(choiceSetId: string, options?: ChoiceSetDeleteByIdOptions): Promise { - await this.post( + await this.delete( DATA_FABRIC_ENDPOINTS.CHOICESETS.DELETE(choiceSetId), - {}, { headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }) }, ); } diff --git a/src/services/data-fabric/entities.ts b/src/services/data-fabric/entities.ts index cf9d2de399..908e2a1a5b 100644 --- a/src/services/data-fabric/entities.ts +++ b/src/services/data-fabric/entities.ts @@ -46,6 +46,9 @@ import { SqlType, FieldDisplayType, ReferenceType, + EntityClass, + EntityCreateExternalSource, + EntityCreateExternalField, } from '../../models/data-fabric/entities.types'; import { PaginatedResponse, NonPaginatedResponse, HasPaginationOptions } from '../../utils/pagination/types'; import { PaginationType } from '../../utils/pagination/internal-types'; @@ -65,8 +68,9 @@ import { ENTITY_FIELD_CONSTRAINT_SPEC, ENTITY_TYPE_IDS, MAX_QUERY_JOINS, + EntityClassToIdMap, } from '../../models/data-fabric/entities.constants'; -import { FieldSchemaPayload, SqlFieldType, EntityFieldConstraint, ResolvedReferenceMeta, EntityJoinPayload } from '../../models/data-fabric/entities.internal-types'; +import { FieldSchemaPayload, SqlFieldType, EntityFieldConstraint, ResolvedReferenceMeta, EntityJoinPayload, FederatedUpsertParts, FederatedUpdateDeltas } from '../../models/data-fabric/entities.internal-types'; import { track } from '../../core/telemetry'; /** Wire values for join types on the name-based multi-entity query route. */ @@ -94,6 +98,38 @@ function toWireJoin(join: EntityJoin, baseEntityName: string): EntityJoinPayload }; } +/** Name of the external object a Federated source reads from (wire shape). */ +function externalSourceObjectName(source: Record): string | undefined { + return (source.externalObjectDetail as Record | undefined)?.externalObjectName as string | undefined; +} + +/** Internal column name of a Federated source field (wire shape uses `fieldDefinition`). */ +function externalSourceFieldName(field: Record): string | undefined { + const def = field.fieldDefinition as Record | undefined; + return (def?.name ?? def?.Name) as string | undefined; +} + +/** + * Carries an existing source forward for the upsert. The only reshaping the write needs + * is defaulting `fieldDisplayType`/`description` on each field definition: the GET omits + * them, but the upsert requires `fieldDisplayType` (a missing one fails the source's field + * validation and surfaces as a misleading join-dependency error). Server-managed identity + * fields round-trip harmlessly, so everything else is kept as-is. + */ +function carryForwardSource(source: Record): Record { + const fields = ((source.fields as Array> | undefined) ?? []).map(f => { + const fieldDefinition: Record = { ...(f.fieldDefinition as Record | undefined) }; + if (fieldDefinition.fieldDisplayType === undefined && fieldDefinition.FieldDisplayType === undefined) { + fieldDefinition.fieldDisplayType = FieldDisplayType.Basic; + } + if (fieldDefinition.description === undefined && fieldDefinition.Description === undefined) { + fieldDefinition.description = ''; + } + return { ...f, fieldDefinition }; + }); + return { ...source, fields }; +} + /** * Unwraps an {@link EntityRef} into the identifier and which Data Fabric route to hit. * Data Fabric exposes parallel by-id and by-name record/attachment routes, so a ref maps @@ -241,13 +277,12 @@ export class EntityService extends BaseService implements EntityServiceModel { @track('Entities.GetAll') async getAll(options?: EntityGetAllOptions): Promise { - // folderKey is preferred over includeFolderEntities: when present, scope to that folder - // via the v1 endpoint + header. Only when no folderKey is given AND includeFolderEntities - // is explicitly true does the SDK switch to the v2 endpoint (returns tenant + folder - // entities together). Default (no options or includeFolderEntities omitted) stays on - // the v1 endpoint = tenant only. - const endpoint = !options?.folderKey && options?.includeFolderEntities - ? DATA_FABRIC_ENDPOINTS.ENTITY.GET_ALL_V2 + // Use the v3 endpoint whenever a folder scope is requested: a folderKey scopes to that + // folder via the header, and includeFolderEntities returns tenant + folder entities + // together. Only the default (no folderKey and includeFolderEntities omitted) stays on + // the v1 endpoint = tenant only, since v3 has no tenant-only listing. + const endpoint = options?.folderKey || options?.includeFolderEntities + ? DATA_FABRIC_ENDPOINTS.ENTITY.GET_ALL_V3 : DATA_FABRIC_ENDPOINTS.ENTITY.GET_ALL; const response = await this.get( @@ -372,7 +407,24 @@ export class EntityService extends BaseService implements EntityServiceModel { @track('Entities.Create') async create(name: string, fields: EntityCreateFieldOptions[], options?: EntityCreateOptions): Promise { const opts = options ?? {}; + // entityClassId is only sent when a class is explicitly chosen — native creates + // stay byte-identical to the legacy shape (no discriminator). + let entityClassId: number | undefined; + if (opts.entityClass !== undefined) { + entityClassId = EntityClassToIdMap[opts.entityClass]; + if (entityClassId === undefined) { + throw new ValidationError({ + message: `entityClass '${opts.entityClass}' is not creatable. Use EntityClass.Native or EntityClass.Federated.`, + }); + } + } + if (opts.entityClass === EntityClass.Federated && !opts.externalFields?.length) { + throw new ValidationError({ + message: 'Federated entities require at least one external source in `externalFields`.', + }); + } const fieldPayloads = await this.buildFieldsWithReferenceMeta(fields); + const externalFields = await this.buildExternalSourcesPayload(opts.externalFields); const payload = { ...(opts.description !== undefined && { description: opts.description }), displayName: opts.displayName ?? name, @@ -382,7 +434,9 @@ export class EntityService extends BaseService implements EntityServiceModel { folderId: opts.folderKey ?? DATA_FABRIC_TENANT_FOLDER_ID, isRbacEnabled: opts.isRbacEnabled ?? false, isInsightsEnabled: opts.isAnalyticsEnabled ?? false, - externalFields: opts.externalFields ?? [], + externalFields, + ...(entityClassId !== undefined && { entityClassId }), + ...(opts.sourceJoinConditionDetails !== undefined && { sourceJoinConditionDetails: opts.sourceJoinConditionDetails }), }, }; const response = await this.post( @@ -404,12 +458,21 @@ export class EntityService extends BaseService implements EntityServiceModel { @track('Entities.UpdateById') async updateById(id: string, options?: EntityUpdateByIdOptions): Promise { const opts = options ?? {}; - const hasSchemaChanges = !!(opts.addFields?.length || opts.removeFields?.length || opts.updateFields?.length); + const hasFederatedChanges = !!( + opts.addExternalSources?.length || + opts.removeExternalSources?.length || + opts.addFieldsToSource?.length || + opts.removeFieldsFromSource?.length || + opts.updateExternalFieldMapping?.length || + opts.addSourceJoins?.length || + opts.updateSourceJoin?.length + ); + const hasSchemaChanges = !!(opts.addFields?.length || opts.removeFields?.length || opts.updateFields?.length) || hasFederatedChanges; const hasMetadataChanges = opts.displayName !== undefined || opts.description !== undefined || opts.isRbacEnabled !== undefined; if (!hasSchemaChanges && !hasMetadataChanges) { throw new ValidationError({ - message: 'updateById requires at least one change — pass addFields, removeFields, updateFields, displayName, description, or isRbacEnabled.', + message: 'updateById requires at least one change — pass addFields, removeFields, updateFields, displayName, description, isRbacEnabled, or a federated source/join delta (addExternalSources, removeExternalSources, addFieldsToSource, removeFieldsFromSource, updateExternalFieldMapping, addSourceJoins, updateSourceJoin).', }); } @@ -418,7 +481,7 @@ export class EntityService extends BaseService implements EntityServiceModel { } if (hasMetadataChanges) { await this.patch( - DATA_FABRIC_ENDPOINTS.ENTITY.UPDATE(id), + DATA_FABRIC_ENDPOINTS.ENTITY.UPDATE_METADATA(id), { ...(opts.displayName !== undefined && { displayName: opts.displayName }), ...(opts.description !== undefined && { description: opts.description }), @@ -436,7 +499,7 @@ export class EntityService extends BaseService implements EntityServiceModel { * @param options - Field changes to apply * @private */ - private async applySchemaUpdate(entityId: string, options: Pick): Promise { + private async applySchemaUpdate(entityId: string, options: Pick & FederatedUpdateDeltas): Promise { const folderHeaders = createHeaders({ [FOLDER_KEY]: options.folderKey }); const entityResponse = await this.get( DATA_FABRIC_ENDPOINTS.ENTITY.GET_BY_ID(entityId), @@ -444,9 +507,12 @@ export class EntityService extends BaseService implements EntityServiceModel { ); const raw = entityResponse.data; - // Carry forward existing non-system fields from GET response (skip system/primary-key fields) + // Carry forward existing non-system fields from GET response (skip system/primary-key + // fields). Exclude external fields: on a Federated entity the GET flattens the external + // source fields into `fields` too (isExternalField=true); they belong only under + // `externalFields`, so reposting them here duplicates them and the upsert fails. let fields: FieldMetaData[] = (raw.fields ?? []) - .filter(f => !f.isSystemField && !f.isPrimaryKey); + .filter(f => !f.isSystemField && !f.isPrimaryKey && !f.isExternalField); // Filter out removed fields if (options.removeFields?.length) { @@ -509,6 +575,11 @@ export class EntityService extends BaseService implements EntityServiceModel { newFields.push(...await this.buildFieldsWithReferenceMeta(options.addFields)); } + // Carry forward (and, for Federated entities, translate + apply deltas to) the + // external sources and joins. Reposting the raw `externalFields` alone would drop + // the joins and class discriminator — the v3 upsert is a full-definition replace. + const federated = await this.buildFederatedUpsertParts(raw, options); + await this.post( DATA_FABRIC_ENDPOINTS.ENTITY.UPSERT, { @@ -523,7 +594,9 @@ export class EntityService extends BaseService implements EntityServiceModel { // `raw` is the untransformed GET response, so read the wire key `isInsightsEnabled` // directly (it is not on the public type, which exposes it as `isAnalyticsEnabled`). isInsightsEnabled: (raw as { isInsightsEnabled?: boolean }).isInsightsEnabled ?? false, - externalFields: raw.externalFields ?? [], + externalFields: federated.externalFields, + ...(federated.entityClassId !== undefined && { entityClassId: federated.entityClassId }), + ...(federated.sourceJoinConditionDetails !== undefined && { sourceJoinConditionDetails: federated.sourceJoinConditionDetails }), }, }, { headers: folderHeaders }, @@ -547,6 +620,113 @@ export class EntityService extends BaseService implements EntityServiceModel { return transformData(response.data, EntityMap).name; } + /** + * Translates a raw GET into the write-ready Federated parts, then applies any + * source/join deltas. Joins arrive as `sourceJoinCriterias` (object/field IDs) and + * are resolved to `sourceJoinConditionDetails` (object names + connection ids) using + * each source's `externalObjectDetail.id` → connection map. `joinType` passes through + * as-is (the API accepts both the string form and the numeric form). + */ + private async buildFederatedUpsertParts( + raw: RawEntityGetResponse, + options: FederatedUpdateDeltas, + ): Promise { + // Carry forward current sources (only `fieldDisplayType`/`description` need defaulting). + let externalFields: Array> = (raw.externalFields ?? []).map(s => carryForwardSource({ ...s } as Record)); + let joins = this.translateSourceJoins(raw); + const entityClassId = raw.entityClass ? EntityClassToIdMap[raw.entityClass] : undefined; + + const findSource = (name: string): Record | undefined => + externalFields.find(s => externalSourceObjectName(s) === name); + + if (options.addExternalSources?.length) { + externalFields.push(...await this.buildExternalSourcesPayload(options.addExternalSources)); + } + if (options.removeExternalSources?.length) { + const remove = new Set(options.removeExternalSources); + externalFields = externalFields.filter(s => !remove.has(externalSourceObjectName(s) ?? '')); + // Cascade: a join can't outlive its source. Drop any join that references a removed + // source — otherwise it dangles (and, once the source is gone, can't be targeted by + // name to remove later). Join names were resolved from the pre-removal GET. + joins = joins.filter(j => !remove.has(j.sourceObjectName as string) && !remove.has(j.relatedSourceObjectName as string)); + } + if (options.addFieldsToSource?.length) { + for (const add of options.addFieldsToSource) { + const src = findSource(add.sourceObjectName); + if (!src) throw new ValidationError({ message: `Cannot add fields: source '${add.sourceObjectName}' not found on the entity.` }); + const builtFields = await this.buildExternalFieldsPayload(add.fields); + const existing = (src.fields as Array> | undefined) ?? []; + src.fields = [...existing, ...builtFields]; + } + } + if (options.removeFieldsFromSource?.length) { + for (const rem of options.removeFieldsFromSource) { + const src = findSource(rem.sourceObjectName); + if (!src) throw new ValidationError({ message: `Cannot remove fields: source '${rem.sourceObjectName}' not found on the entity.` }); + const drop = new Set(rem.fieldNames); + src.fields = ((src.fields as Array> | undefined) ?? []).filter(f => !drop.has(externalSourceFieldName(f) ?? '')); + } + } + if (options.updateExternalFieldMapping?.length) { + for (const up of options.updateExternalFieldMapping) { + const src = findSource(up.sourceObjectName); + if (!src) throw new ValidationError({ message: `Cannot update mapping: source '${up.sourceObjectName}' not found on the entity.` }); + const field = ((src.fields as Array> | undefined) ?? []).find(f => externalSourceFieldName(f) === up.fieldName); + if (!field) throw new ValidationError({ message: `Cannot update mapping: field '${up.fieldName}' not found on source '${up.sourceObjectName}'.` }); + field.externalFieldMappingDetail = { ...(field.externalFieldMappingDetail as Record), ...up.mapping }; + } + } + if (options.addSourceJoins?.length) { + joins.push(...options.addSourceJoins.map(j => ({ ...j } as Record))); + } + if (options.updateSourceJoin?.length) { + for (const up of options.updateSourceJoin) { + const join = joins.find(j => j.sourceObjectName === up.sourceObjectName && j.relatedSourceObjectName === up.relatedSourceObjectName); + if (!join) throw new ValidationError({ message: `Cannot update join: no join between '${up.sourceObjectName}' and '${up.relatedSourceObjectName}'.` }); + if (up.sourceJoinField !== undefined) join.sourceJoinField = up.sourceJoinField; + if (up.relatedSourceJoinField !== undefined) join.relatedSourceJoinField = up.relatedSourceJoinField; + if (up.joinType !== undefined) join.joinType = up.joinType; + } + } + + return { + externalFields, + ...(entityClassId !== undefined && { entityClassId }), + ...(joins.length > 0 && { sourceJoinConditionDetails: joins }), + }; + } + + /** + * Resolves read-shape `sourceJoinCriterias` (object/field IDs) into write-shape + * `sourceJoinConditionDetails` (object names + connection ids). The connection id for a + * source is its `externalConnectionDetail.connectionId` (external) or + * `nativeConnectionDetail.entityId` (a Native source referencing another UiPath entity). + */ + private translateSourceJoins(raw: RawEntityGetResponse): Array> { + const byObjectId = new Map(); + for (const source of raw.externalFields ?? []) { + const objectId = source.externalObjectDetail?.id; + if (!objectId) continue; + byObjectId.set(objectId, { + name: source.externalObjectDetail?.externalObjectName, + connectionId: source.externalConnectionDetail?.connectionId ?? source.nativeConnectionDetail?.entityId, + }); + } + return (raw.sourceJoinCriterias ?? []).map(join => { + const src = byObjectId.get(join.sourceObjectId ?? ''); + const related = byObjectId.get(join.relatedSourceObjectId ?? ''); + return { + sourceObjectName: src?.name, + sourceJoinField: join.joinFieldName, + sourceObjectConnectionId: src?.connectionId, + joinType: join.joinType, + relatedSourceObjectName: related?.name, + relatedSourceJoinField: join.relatedSourceFieldName, + relatedSourceObjectConnectionId: related?.connectionId, + }; + }); + } + /** * Orchestrates all field mapping transformations * @@ -644,6 +824,36 @@ export class EntityService extends BaseService implements EntityServiceModel { return fields.map((f, i) => this.buildSchemaFieldPayload(f, metas[i])); } + /** Builds the wire `fields` payload for a Federated source (field definition + mapping). */ + private async buildExternalFieldsPayload( + fields?: EntityCreateExternalField[], + ): Promise>> { + const list = fields ?? []; + const fieldDefs = await this.buildFieldsWithReferenceMeta(list.map(f => f.field)); + return list.map((f, i) => ({ + fieldDefinition: fieldDefs[i], + externalFieldMappingDetail: f.externalFieldMappingDetail, + })); + } + + /** + * Builds the wire `externalFields` payload for a Federated entity. Each source's + * internal columns run through the same {@link buildFieldsWithReferenceMeta} pipeline + * as native fields (so `fieldDefinition` is identical to a native field), then pair + * with their external mapping and source connection/object details. + */ + private async buildExternalSourcesPayload( + sources?: EntityCreateExternalSource[], + ): Promise>> { + if (!sources?.length) return []; + return Promise.all(sources.map(async source => ({ + fields: await this.buildExternalFieldsPayload(source.fields), + externalObjectDetail: source.externalObjectDetail, + ...(source.externalConnectionDetail !== undefined && { externalConnectionDetail: source.externalConnectionDetail }), + ...(source.nativeConnectionDetail !== undefined && { nativeConnectionDetail: source.nativeConnectionDetail }), + }))); + } + // Choice-set targets resolve server-side by NAME (the API rejects cross-folder // refs with empty target name even when folderId is supplied), so the SDK // fetches the name once for each cross-folder choice-set field. Relationship @@ -984,9 +1194,7 @@ export class EntityService extends BaseService implements EntityServiceModel { } // folderKey is header-only; expansionLevel must be sent as a query param by PaginationHelpers. const { folderKey, expansionLevel, ...rest } = options ?? {}; - // The multi-entity (joins) contract only exists on the name-based query route — - // the ID-based route silently drops the `joins` body key. When addressing by id, resolve - // the name (by name, it is already known); then translate each join to the wire shape. + // The v3 by-id route rejects joins; resolve the name and address by name for join queries. let getEndpoint = () => byId ? DATA_FABRIC_ENDPOINTS.ENTITY.QUERY_BY_ID(identifier) : DATA_FABRIC_ENDPOINTS.ENTITY.QUERY_BY_NAME(identifier); diff --git a/src/utils/constants/endpoints/data-fabric.ts b/src/utils/constants/endpoints/data-fabric.ts index 61f1bb87d0..49cd152554 100644 --- a/src/utils/constants/endpoints/data-fabric.ts +++ b/src/utils/constants/endpoints/data-fabric.ts @@ -17,28 +17,29 @@ export const DATA_FABRIC_TENANT_FOLDER_ID = '00000000-0000-0000-0000-00000000000 export const DATA_FABRIC_ENDPOINTS = { ENTITY: { GET_ALL: `${DATAFABRIC_BASE}/api/Entity`, - // Lists tenant-level and folder-level entities together. - // Used by getAll when includeFolderEntities is true. - GET_ALL_V2: `${DATAFABRIC_BASE}/api/v2/Entity`, - GET_ENTITY_RECORDS: (entityId: string) => `${DATAFABRIC_BASE}/api/EntityService/entity/${entityId}/read`, - GET_BY_ID: (entityId: string) => `${DATAFABRIC_BASE}/api/Entity/${entityId}`, - // v2 single-record read. Returns the full record including complete MULTILINE_MAX - // content, unlike list/query endpoints which project only a preview for those fields. + // v3 listing of tenant-level and folder-level entities together. + // Used by getAll when folderKey is set or includeFolderEntities is true. + GET_ALL_V3: `${DATAFABRIC_BASE}/api/v3/entities`, + GET_ENTITY_RECORDS: (entityId: string) => `${DATAFABRIC_BASE}/api/v3/entities/entity/${entityId}/read`, + GET_BY_ID: (entityId: string) => `${DATAFABRIC_BASE}/api/v3/entities/${entityId}`, + // v3 single-record read. Returns the full record including complete MULTILINE_MAX + // content, unlike list/query endpoints which project a size marker for those fields. GET_RECORD_BY_ID: (entityId: string, recordId: string) => - `${DATAFABRIC_BASE}/api/v2/EntityService/entity/${entityId}/read/${recordId}`, - INSERT_BY_ID: (entityId: string) => `${DATAFABRIC_BASE}/api/EntityService/entity/${entityId}/insert`, - BATCH_INSERT_BY_ID: (entityId: string) => `${DATAFABRIC_BASE}/api/EntityService/entity/${entityId}/insert-batch`, - UPDATE_RECORD_BY_ID: (entityId: string, recordId: string) => `${DATAFABRIC_BASE}/api/EntityService/entity/${entityId}/update/${recordId}`, - UPDATE_BY_ID: (entityId: string) => `${DATAFABRIC_BASE}/api/EntityService/entity/${entityId}/update-batch`, - DELETE_RECORD_BY_ID: (entityId: string, recordId: string) => `${DATAFABRIC_BASE}/api/EntityService/entity/${entityId}/delete/${recordId}`, - DELETE_BY_ID: (entityId: string) => `${DATAFABRIC_BASE}/api/EntityService/entity/${entityId}/delete-batch`, - UPSERT: `${DATAFABRIC_BASE}/api/Entity`, - DELETE: (entityId: string) => `${DATAFABRIC_BASE}/api/Entity/${entityId}`, - UPDATE: (entityId: string) => `${DATAFABRIC_BASE}/api/Entity/${entityId}/metadata`, - QUERY_BY_ID: (entityId: string) => `${DATAFABRIC_BASE}/api/EntityService/entity/${entityId}/query`, - // Name-based structured query. The multi-entity (joins) contract is only - // implemented on this route — QUERY_BY_ID silently drops the `joins` body key. - QUERY_BY_NAME: (entityName: string) => `${DATAFABRIC_BASE}/api/EntityService/${entityName}/query`, + `${DATAFABRIC_BASE}/api/v3/entities/entity/${entityId}/read/${recordId}`, + INSERT_BY_ID: (entityId: string) => `${DATAFABRIC_BASE}/api/v3/entities/entity/${entityId}/insert`, + BATCH_INSERT_BY_ID: (entityId: string) => `${DATAFABRIC_BASE}/api/v3/entities/entity/${entityId}/insert-batch`, + UPDATE_RECORD_BY_ID: (entityId: string, recordId: string) => `${DATAFABRIC_BASE}/api/v3/entities/entity/${entityId}/update/${recordId}`, + UPDATE_BY_ID: (entityId: string) => `${DATAFABRIC_BASE}/api/v3/entities/entity/${entityId}/update-batch`, + DELETE_RECORD_BY_ID: (entityId: string, recordId: string) => `${DATAFABRIC_BASE}/api/v3/entities/entity/${entityId}/delete/${recordId}`, + DELETE_BY_ID: (entityId: string) => `${DATAFABRIC_BASE}/api/v3/entities/entity/${entityId}/delete-batch`, + // Same URL as GET_ALL_V3; the HTTP method (POST for create/upsert vs GET for list) is resolved at the call site. + UPSERT: `${DATAFABRIC_BASE}/api/v3/entities`, + // Same URL as GET_BY_ID; the HTTP method (DELETE vs GET) is resolved at the call site. + DELETE: (entityId: string) => `${DATAFABRIC_BASE}/api/v3/entities/${entityId}`, + UPDATE_METADATA: (entityId: string) => `${DATAFABRIC_BASE}/api/v3/entities/${entityId}/metadata`, + // v3 query — Federated-capable; by-name handles joins, by-id rejects them. + QUERY_BY_ID: (entityId: string) => `${DATAFABRIC_BASE}/api/v3/entities/entity/${entityId}/query`, + QUERY_BY_NAME: (entityName: string) => `${DATAFABRIC_BASE}/api/v3/entities/${entityName}/query`, BULK_UPLOAD_BY_ID: (entityId: string) => `${DATAFABRIC_BASE}/api/EntityService/entity/${entityId}/bulk-upload`, DOWNLOAD_ATTACHMENT: (entityId: string, recordId: string, fieldName: string) => `${DATAFABRIC_BASE}/api/Attachment/entity/${entityId}/${recordId}/${fieldName}`, @@ -51,28 +52,31 @@ export const DATA_FABRIC_ENDPOINTS = { // The Data Fabric API exposes a parallel `{entityName}/...` route for every // record operation, letting callers address an entity by name instead of id // (used by solution binding overrides, which resolve resources by name + folderKey). - GET_BY_NAME: (entityName: string) => `${DATAFABRIC_BASE}/api/Entity/${entityName}/metadata`, - GET_ENTITY_RECORDS_BY_NAME: (entityName: string) => `${DATAFABRIC_BASE}/api/EntityService/${entityName}/read`, - // v2 single-record read by name — mirrors GET_RECORD_BY_ID (full MULTILINE_MAX content). + GET_BY_NAME: (entityName: string) => `${DATAFABRIC_BASE}/api/v3/entities/${entityName}/metadata`, + GET_ENTITY_RECORDS_BY_NAME: (entityName: string) => `${DATAFABRIC_BASE}/api/v3/entities/${entityName}/read`, + // v3 single-record read by name — mirrors GET_RECORD_BY_ID (full MULTILINE_MAX content). GET_RECORD_BY_NAME: (entityName: string, recordId: string) => - `${DATAFABRIC_BASE}/api/v2/EntityService/${entityName}/read/${recordId}`, - INSERT_BY_NAME: (entityName: string) => `${DATAFABRIC_BASE}/api/EntityService/${entityName}/insert`, - BATCH_INSERT_BY_NAME: (entityName: string) => `${DATAFABRIC_BASE}/api/EntityService/${entityName}/insert-batch`, - UPDATE_RECORD_BY_NAME: (entityName: string, recordId: string) => `${DATAFABRIC_BASE}/api/EntityService/${entityName}/update/${recordId}`, - UPDATE_BY_NAME: (entityName: string) => `${DATAFABRIC_BASE}/api/EntityService/${entityName}/update-batch`, - DELETE_RECORD_BY_NAME: (entityName: string, recordId: string) => `${DATAFABRIC_BASE}/api/EntityService/${entityName}/delete/${recordId}`, - DELETE_BY_NAME: (entityName: string) => `${DATAFABRIC_BASE}/api/EntityService/${entityName}/delete-batch`, + `${DATAFABRIC_BASE}/api/v3/entities/${entityName}/read/${recordId}`, + INSERT_BY_NAME: (entityName: string) => `${DATAFABRIC_BASE}/api/v3/entities/${entityName}/insert`, + BATCH_INSERT_BY_NAME: (entityName: string) => `${DATAFABRIC_BASE}/api/v3/entities/${entityName}/insert-batch`, + UPDATE_RECORD_BY_NAME: (entityName: string, recordId: string) => `${DATAFABRIC_BASE}/api/v3/entities/${entityName}/update/${recordId}`, + UPDATE_BY_NAME: (entityName: string) => `${DATAFABRIC_BASE}/api/v3/entities/${entityName}/update-batch`, + DELETE_RECORD_BY_NAME: (entityName: string, recordId: string) => `${DATAFABRIC_BASE}/api/v3/entities/${entityName}/delete/${recordId}`, + DELETE_BY_NAME: (entityName: string) => `${DATAFABRIC_BASE}/api/v3/entities/${entityName}/delete-batch`, BULK_UPLOAD_BY_NAME: (entityName: string) => `${DATAFABRIC_BASE}/api/EntityService/${entityName}/bulk-upload`, // Download (GET), upload (POST), and delete (DELETE) all share this URL; the HTTP method is chosen at the call site. ATTACHMENT_BY_NAME: (entityName: string, recordId: string, fieldName: string) => `${DATAFABRIC_BASE}/api/Attachment/${entityName}/${recordId}/${fieldName}`, }, CHOICESETS: { + // A choice set is an entity: UPDATE/DELETE reuse the v3 entity routes (same URLs as + // ENTITY.UPDATE_METADATA / ENTITY.DELETE). The /choiceset create/list + value writes have + // no v3 equivalent and stay on v1. GET_ALL: `${DATAFABRIC_BASE}/api/Entity/choiceset`, - GET_BY_ID: (choiceSetId: string) => `${DATAFABRIC_BASE}/api/EntityService/entity/${choiceSetId}/query_expansion`, + GET_BY_ID: (choiceSetId: string) => `${DATAFABRIC_BASE}/api/v3/entities/entity/${choiceSetId}/query_expansion`, CREATE: `${DATAFABRIC_BASE}/api/Entity/choiceset`, - UPDATE: (choiceSetId: string) => `${DATAFABRIC_BASE}/api/Entity/${choiceSetId}/metadata`, - DELETE: (choiceSetId: string) => `${DATAFABRIC_BASE}/api/Entity/${choiceSetId}/delete`, + UPDATE: (choiceSetId: string) => `${DATAFABRIC_BASE}/api/v3/entities/${choiceSetId}/metadata`, + DELETE: (choiceSetId: string) => `${DATAFABRIC_BASE}/api/v3/entities/${choiceSetId}`, INSERT_BY_NAME: (choiceSetName: string) => `${DATAFABRIC_BASE}/api/EntityService/${choiceSetName}/choiceset/insert`, UPDATE_BY_NAME: (choiceSetName: string, valueId: string) => `${DATAFABRIC_BASE}/api/EntityService/${choiceSetName}/choiceset/${valueId}/update`, DELETE_BY_ID: (choiceSetId: string) => `${DATAFABRIC_BASE}/api/EntityService/entity/${choiceSetId}/choiceset/delete`, diff --git a/tests/integration/shared/data-fabric/entities-schema.integration.test.ts b/tests/integration/shared/data-fabric/entities-schema.integration.test.ts index e7519b7a3a..e806401b6b 100644 --- a/tests/integration/shared/data-fabric/entities-schema.integration.test.ts +++ b/tests/integration/shared/data-fabric/entities-schema.integration.test.ts @@ -8,6 +8,8 @@ import { import { registerResource } from '../../utils/cleanup'; import { awaitRecordVisible, createEntityAwaitingReady, generateRandomString } from '../../utils/helpers'; import { + DataDirectionType, + EntityClass, EntityFieldDataType, EntityRecord, FieldDisplayType, @@ -206,6 +208,142 @@ describeIntegration('Data Fabric Entities Schema - Integration Tests', 'both', m const updated = after.fields.find(f => f.name === 'toUpdate'); expect(updated?.displayName).toBe('After Update'); }, 90_000); + + it('should enable analytics via isAnalyticsEnabled at create time', async () => { + const { entities } = getServices(); + const name = `sdk_test_${generateRandomString(8).toLowerCase()}`; + const displayName = `SDK Analytics ${name}`; + // isInsightsEnabled is immutable after creation, so analytics must be set on create. + const entityId = await createEntityAwaitingReady(entities, name, [], { displayName, isAnalyticsEnabled: true }); + createdEntityIds.push(entityId); + + const created = await entities.getById(entityId); + expect(created.isAnalyticsEnabled).toBe(true); + }, 90_000); + }); + + // Skipped unless a live Integration Service connection fixture is configured (a federated + // entity requires a connector source), plus DataFabric.Schema.Write scope. Standard CI has + // neither. To run locally against a federated-capable tenant, set: DF_FED_CONNECTION_ID, + // DF_FED_ELEMENT_INSTANCE_ID, DF_FED_CONNECTOR_KEY, DF_FED_OBJECT, DF_FED_OBJECT_METHOD + // (the operations-catalog JSON string from `is resources describe + // --operation List`), DF_FED_PRIMARY_KEY, DF_FED_FIELD (an external field name on the object). + const federatedEnvReady = Boolean( + process.env.DF_FED_CONNECTION_ID && + process.env.DF_FED_ELEMENT_INSTANCE_ID && + process.env.DF_FED_CONNECTOR_KEY && + process.env.DF_FED_OBJECT && + process.env.DF_FED_OBJECT_METHOD && + process.env.DF_FED_FIELD, + ); + describe.skipIf(!federatedEnvReady)('updateById — federated source & join deltas', () => { + const entityFolderKey = getTestConfig().folderKey; + const conn = { + connectionId: process.env.DF_FED_CONNECTION_ID ?? '', + elementInstanceId: Number(process.env.DF_FED_ELEMENT_INSTANCE_ID ?? 0), + connectorKey: process.env.DF_FED_CONNECTOR_KEY ?? '', + connectorName: process.env.DF_FED_CONNECTOR_NAME ?? process.env.DF_FED_CONNECTOR_KEY ?? '', + folderKey: process.env.DF_FED_FOLDER_KEY ?? entityFolderKey, + }; + const objectName = process.env.DF_FED_OBJECT ?? ''; + const method = process.env.DF_FED_OBJECT_METHOD ?? ''; + const primaryKey = process.env.DF_FED_PRIMARY_KEY ?? 'Id'; + const externalField = process.env.DF_FED_FIELD ?? ''; + + async function createSingleSourceFederated(): Promise { + const { entities } = getServices(); + const name = `sdk_fed_${generateRandomString(8).toLowerCase()}`; + const id = await entities.create(name, [], { + folderKey: entityFolderKey, + entityClass: EntityClass.Federated, + externalFields: [ + { + externalConnectionDetail: conn, + externalObjectDetail: { externalObjectName: objectName, primaryKey, isPrimarySource: true, method }, + fields: [ + { + field: { name: 'PkField', type: EntityFieldDataType.STRING }, + externalFieldMappingDetail: { externalFieldName: primaryKey, externalFieldType: 'string', directionType: DataDirectionType.ReadOnly }, + }, + ], + }, + ], + }); + createdEntityIds.push(id); + return id; + } + + it('should create a single-source federated entity with EntityClass.Federated', async () => { + const { entities } = getServices(); + const id = await createSingleSourceFederated(); + + const got = await entities.getById(id, { folderKey: entityFolderKey }); + expect(got.entityClass).toBe(EntityClass.Federated); + expect(got.externalFields?.length).toBe(1); + }, 90_000); + + it('should add a field to an existing source and preserve the source', async () => { + const { entities } = getServices(); + const id = await createSingleSourceFederated(); + + await entities.updateById(id, { + folderKey: entityFolderKey, + addFieldsToSource: [ + { + sourceObjectName: objectName, + fields: [ + { + field: { name: 'AddedField', type: EntityFieldDataType.STRING }, + externalFieldMappingDetail: { externalFieldName: externalField, externalFieldType: 'string', directionType: DataDirectionType.ReadOnly }, + }, + ], + }, + ], + }); + + const got = await entities.getById(id, { folderKey: entityFolderKey }); + const source = got.externalFields?.find(s => s.externalObjectDetail?.externalObjectName === objectName); + const names = (source?.fields ?? []).map(f => f.fieldMetaData?.name); + expect(names).toContain('PkField'); + expect(names).toContain('AddedField'); + }, 90_000); + + it('should remove a field from a source and keep the source and its other fields', async () => { + const { entities } = getServices(); + const id = await createSingleSourceFederated(); + + // Add a second field, then remove it — a real removeFieldsFromSource round-trip. + await entities.updateById(id, { + folderKey: entityFolderKey, + addFieldsToSource: [ + { + sourceObjectName: objectName, + fields: [ + { + field: { name: 'RemovableField', type: EntityFieldDataType.STRING }, + externalFieldMappingDetail: { externalFieldName: externalField, externalFieldType: 'string', directionType: DataDirectionType.ReadOnly }, + }, + ], + }, + ], + }); + await entities.updateById(id, { + folderKey: entityFolderKey, + removeFieldsFromSource: [{ sourceObjectName: objectName, fieldNames: ['RemovableField'] }], + }); + + const got = await entities.getById(id, { folderKey: entityFolderKey }); + const source = got.externalFields?.find(s => s.externalObjectDetail?.externalObjectName === objectName); + const names = (source?.fields ?? []).map(f => f.fieldMetaData?.name); + expect(got.externalFields?.length).toBe(1); + expect(names).toContain('PkField'); + expect(names).not.toContain('RemovableField'); + }, 90_000); + + // Cascade (join dropped when its source is removed via removeExternalSources) needs a + // two-source + join fixture — a second connector object that standard env vars don't + // configure. Left visible rather than faked on a single-source entity. + it.todo('should cascade-remove a join when its source is removed'); }); describe('sqlType constraint defaults', () => { @@ -474,7 +612,7 @@ describeIntegration('Data Fabric Entities Schema - Integration Tests', 'both', m for (const [level, rec] of [[1, l1], [2, l2], [3, l3]] as const) { expect(typeof rec.parent, `L${level} parent should be object`).toBe('object'); expect(rec.parent, `L${level} parent should not be null`).not.toBeNull(); - expect(rec.parent, `L${level} parent should carry target Id`).toHaveProperty('Id', targetRecordId); + expect(rec.parent.Id, `L${level} parent should carry target Id`).toBe(targetRecordId); } // L2 surfaces the user-defined `label` field from the target record. diff --git a/tests/unit/services/data-fabric/choicesets.test.ts b/tests/unit/services/data-fabric/choicesets.test.ts index f17253d2fa..bdce324364 100644 --- a/tests/unit/services/data-fabric/choicesets.test.ts +++ b/tests/unit/services/data-fabric/choicesets.test.ts @@ -548,21 +548,20 @@ describe('ChoiceSetService Unit Tests', () => { }); describe('deleteById', () => { - it('should POST to the delete endpoint with empty body', async () => { - mockApiClient.post.mockResolvedValue(true); + it('should DELETE the choice-set via the v3 entity endpoint', async () => { + mockApiClient.delete.mockResolvedValue(true); await choiceSetService.deleteById(CHOICESET_TEST_CONSTANTS.CHOICESET_ID); - expect(mockApiClient.post).toHaveBeenCalledWith( + expect(mockApiClient.delete).toHaveBeenCalledWith( DATA_FABRIC_ENDPOINTS.CHOICESETS.DELETE(CHOICESET_TEST_CONSTANTS.CHOICESET_ID), - {}, { headers: {} }, ); }); it('should handle API errors', async () => { const error = createMockError(TEST_CONSTANTS.ERROR_MESSAGE); - mockApiClient.post.mockRejectedValue(error); + mockApiClient.delete.mockRejectedValue(error); await expect( choiceSetService.deleteById(CHOICESET_TEST_CONSTANTS.CHOICESET_ID), @@ -570,15 +569,14 @@ describe('ChoiceSetService Unit Tests', () => { }); it('should pass folderKey via X-UIPATH-FolderKey header when provided', async () => { - mockApiClient.post.mockResolvedValue(true); + mockApiClient.delete.mockResolvedValue(true); await choiceSetService.deleteById(CHOICESET_TEST_CONSTANTS.CHOICESET_ID, { folderKey: CHOICESET_TEST_CONSTANTS.FOLDER_KEY, }); - expect(mockApiClient.post).toHaveBeenCalledWith( + expect(mockApiClient.delete).toHaveBeenCalledWith( DATA_FABRIC_ENDPOINTS.CHOICESETS.DELETE(CHOICESET_TEST_CONSTANTS.CHOICESET_ID), - {}, { headers: { 'X-UIPATH-FolderKey': CHOICESET_TEST_CONSTANTS.FOLDER_KEY } }, ); }); diff --git a/tests/unit/services/data-fabric/entities.test.ts b/tests/unit/services/data-fabric/entities.test.ts index fc243e7652..b45447eb98 100644 --- a/tests/unit/services/data-fabric/entities.test.ts +++ b/tests/unit/services/data-fabric/entities.test.ts @@ -40,12 +40,13 @@ import { EntityAggregateFunction, EntityHavingOperator, EntityType, - ExternalField, FieldDisplayType, JoinType, LogicalOperator, QueryFilterOperator, RawEntityGetResponse, + EntityClass, + DataDirectionType, } from "../../../../src/models/data-fabric/entities.types"; import { EntityFieldTypeMap, @@ -58,7 +59,7 @@ import { TEST_CONSTANTS } from "../../../utils/constants/common"; import { DATA_FABRIC_ENDPOINTS } from "../../../../src/utils/constants/endpoints"; import { DATA_FABRIC_TENANT_FOLDER_ID } from "../../../../src/utils/constants/endpoints/data-fabric"; import { ValidationError } from "../../../../src/core/errors"; -import { SqlFieldType, FieldSchemaPayload } from "@/models/data-fabric/entities.internal-types"; +import { SqlFieldType, FieldSchemaPayload, EntityClassId } from "@/models/data-fabric/entities.internal-types"; // ===== MOCKING ===== // Mock the dependencies @@ -430,24 +431,24 @@ describe("EntityService Unit Tests", () => { ); }); - it("should pass folderKey via X-UIPATH-FolderKey header when provided", async () => { + it("should call the v3 endpoint and pass folderKey via X-UIPATH-FolderKey header when provided", async () => { mockApiClient.get.mockResolvedValue([createMockEntityResponse()]); await entityService.getAll({ folderKey: ENTITY_TEST_CONSTANTS.FIELD_ID }); expect(mockApiClient.get).toHaveBeenCalledWith( - DATA_FABRIC_ENDPOINTS.ENTITY.GET_ALL, + DATA_FABRIC_ENDPOINTS.ENTITY.GET_ALL_V3, { headers: { "X-UIPATH-FolderKey": ENTITY_TEST_CONSTANTS.FIELD_ID } }, ); }); - it("should call the v2 endpoint when includeFolderEntities is true (cross-scope)", async () => { + it("should call the v3 endpoint when includeFolderEntities is true (cross-scope)", async () => { mockApiClient.get.mockResolvedValue([createMockEntityResponse()]); await entityService.getAll({ includeFolderEntities: true }); expect(mockApiClient.get).toHaveBeenCalledWith( - DATA_FABRIC_ENDPOINTS.ENTITY.GET_ALL_V2, + DATA_FABRIC_ENDPOINTS.ENTITY.GET_ALL_V3, { headers: {} }, ); }); @@ -463,7 +464,7 @@ describe("EntityService Unit Tests", () => { ); }); - it("should let folderKey win and call v1 with the header when both folderKey and includeFolderEntities are provided", async () => { + it("should call the v3 endpoint with the header when both folderKey and includeFolderEntities are provided", async () => { mockApiClient.get.mockResolvedValue([createMockEntityResponse()]); await entityService.getAll({ @@ -472,7 +473,7 @@ describe("EntityService Unit Tests", () => { }); expect(mockApiClient.get).toHaveBeenCalledWith( - DATA_FABRIC_ENDPOINTS.ENTITY.GET_ALL, + DATA_FABRIC_ENDPOINTS.ENTITY.GET_ALL_V3, { headers: { "X-UIPATH-FolderKey": ENTITY_TEST_CONSTANTS.FIELD_ID } }, ); }); @@ -1759,8 +1760,7 @@ describe("EntityService Unit Tests", () => { { headers: {} }, ); const [config, downstream] = vi.mocked(PaginationHelpers.getAll).mock.calls[0]; - // Multi-entity joins only exist on the name-based route — the ID-based - // route silently drops the `joins` body key. + // The v3 by-id route rejects joins, so join queries resolve the name and address by name. expect(config.getEndpoint()).toBe(DATA_FABRIC_ENDPOINTS.ENTITY.QUERY_BY_NAME("Order")); expect(downstream).toMatchObject({ selectedFields: ["Order.amount", "Customer.name", "Region.name"], @@ -1906,6 +1906,23 @@ describe("EntityService Unit Tests", () => { ); }); + it("should target the v3 by-id query endpoint for non-join queries", async () => { + vi.mocked(PaginationHelpers.getAll).mockResolvedValue({ items: [], totalCount: 0 }); + + await entityService.queryRecordsById(ENTITY_TEST_CONSTANTS.ENTITY_ID, { + filterGroup: { + logicalOperator: LogicalOperator.And, + queryFilters: [{ fieldName: "isActive", operator: QueryFilterOperator.Equals, value: "true" }], + }, + }); + + const config = vi.mocked(PaginationHelpers.getAll).mock.calls[0][0]; + expect(config.getEndpoint()).toBe( + DATA_FABRIC_ENDPOINTS.ENTITY.QUERY_BY_ID(ENTITY_TEST_CONSTANTS.ENTITY_ID), + ); + expect(config.getEndpoint()).toContain("/api/v3/entities/entity/"); + }); + it("should throw ValidationError when more than 3 joins are supplied", async () => { const join = { joinType: JoinType.LeftJoin, @@ -2818,7 +2835,7 @@ describe("EntityService Unit Tests", () => { vi.mocked(PaginationHelpers.getAll).mockReset(); }); - it("should delegate to PaginationHelpers.getAll with POST and the by-name query endpoint", async () => { + it("should delegate to PaginationHelpers.getAll with POST and the v3 by-name query endpoint for non-join queries", async () => { const mockResponse = { items: createMockEntityRecords(2), totalCount: 2 }; vi.mocked(PaginationHelpers.getAll).mockResolvedValue(mockResponse); @@ -2826,6 +2843,7 @@ describe("EntityService Unit Tests", () => { { name: ENTITY_TEST_CONSTANTS.ENTITY_NAME }, ); + // Non-join by-name queries use the v3 route so Federated entities are queryable. const [config] = vi.mocked(PaginationHelpers.getAll).mock.calls[0]; expect((config as any).getEndpoint()).toBe( DATA_FABRIC_ENDPOINTS.ENTITY.QUERY_BY_NAME( @@ -3389,26 +3407,137 @@ describe("EntityService Unit Tests", () => { ); }); - it("should pass externalFields when provided", async () => { + it("should send entityClassId and built externalFields for a federated entity", async () => { mockApiClient.post.mockResolvedValue(ENTITY_TEST_CONSTANTS.ENTITY_ID); - const externalFields = [ - { connectionId: ENTITY_TEST_CONSTANTS.EXTERNAL_CONNECTION_ID }, - ] as unknown as ExternalField[]; - - await entityService.create("my_entity", [], { externalFields }); + await entityService.create("sf_accounts", [], { + entityClass: EntityClass.Federated, + externalFields: [ + { + externalConnectionDetail: { + connectionId: ENTITY_TEST_CONSTANTS.EXTERNAL_CONNECTION_ID, + connectorKey: "uipath-salesforce", + connectorName: "Salesforce", + elementInstanceId: 123, + }, + externalObjectDetail: { externalObjectName: "Account", primaryKey: "Id", isPrimarySource: true }, + fields: [ + { + field: { name: "accountName", type: EntityFieldDataType.STRING }, + externalFieldMappingDetail: { externalFieldName: "Name", directionType: DataDirectionType.ReadOnly }, + }, + ], + }, + ], + }); expect(mockApiClient.post).toHaveBeenCalledWith( DATA_FABRIC_ENDPOINTS.ENTITY.UPSERT, expect.objectContaining({ entityDefinition: expect.objectContaining({ - externalFields, + entityClassId: EntityClassId.Federated, + externalFields: [ + expect.objectContaining({ + externalObjectDetail: expect.objectContaining({ externalObjectName: "Account", isPrimarySource: true }), + externalConnectionDetail: expect.objectContaining({ connectionId: ENTITY_TEST_CONSTANTS.EXTERNAL_CONNECTION_ID }), + // internal column runs through the native field-build pipeline (fieldName -> wire `name`) + fields: [ + expect.objectContaining({ + fieldDefinition: expect.objectContaining({ name: "accountName" }), + externalFieldMappingDetail: expect.objectContaining({ + externalFieldName: "Name", + directionType: DataDirectionType.ReadOnly, + }), + }), + ], + }), + ], }), }), { headers: {} }, ); }); + it("should throw when a federated entity has no external sources", async () => { + await expect( + entityService.create("bad_federated", [], { entityClass: EntityClass.Federated }), + ).rejects.toThrow(/require at least one external source/); + expect(mockApiClient.post).not.toHaveBeenCalled(); + }); + + it("should send entityClassId Native when entityClass is Native", async () => { + mockApiClient.post.mockResolvedValue(ENTITY_TEST_CONSTANTS.ENTITY_ID); + + await entityService.create("my_entity", [], { entityClass: EntityClass.Native }); + + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.ENTITY.UPSERT, + expect.objectContaining({ + entityDefinition: expect.objectContaining({ entityClassId: EntityClassId.Native }), + }), + { headers: {} }, + ); + }); + + it("should omit entityClassId for a default (native) create", async () => { + mockApiClient.post.mockResolvedValue(ENTITY_TEST_CONSTANTS.ENTITY_ID); + + await entityService.create("my_entity", []); + + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.ENTITY.UPSERT, + expect.objectContaining({ + entityDefinition: expect.not.objectContaining({ entityClassId: expect.anything() }), + }), + { headers: {} }, + ); + }); + + it("should reject a non-creatable entityClass", async () => { + await expect( + entityService.create("bad_class", [], { entityClass: EntityClass.Case }), + ).rejects.toThrow(/not creatable/); + expect(mockApiClient.post).not.toHaveBeenCalled(); + }); + + it("should pass sourceJoinConditionDetails through for a multi-source federated entity", async () => { + mockApiClient.post.mockResolvedValue(ENTITY_TEST_CONSTANTS.ENTITY_ID); + + const sourceJoinConditionDetails = [ + { + sourceObjectName: "Account", + sourceJoinField: "Id", + joinType: JoinType.LeftJoin, + relatedSourceObjectName: "Contact", + relatedSourceJoinField: "AccountId", + }, + ]; + + await entityService.create("acc_contacts", [], { + entityClass: EntityClass.Federated, + externalFields: [ + { + externalObjectDetail: { externalObjectName: "Account", isPrimarySource: true }, + fields: [ + { + field: { name: "accountName", type: EntityFieldDataType.STRING }, + externalFieldMappingDetail: { externalFieldName: "Name", directionType: DataDirectionType.ReadOnly }, + }, + ], + }, + ], + sourceJoinConditionDetails, + }); + + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.ENTITY.UPSERT, + expect.objectContaining({ + entityDefinition: expect.objectContaining({ sourceJoinConditionDetails }), + }), + { headers: {} }, + ); + }); + it("should handle API errors", async () => { const error = createMockError(TEST_CONSTANTS.ERROR_MESSAGE); mockApiClient.post.mockRejectedValue(error); @@ -3956,6 +4085,241 @@ describe("EntityService Unit Tests", () => { expect(call.entityDefinition.fields).toHaveLength(0); }); + describe("federated sources & joins", () => { + // Mirrors the shape a real v3 GET returns for a federated entity: + // read-shape joins (`sourceJoinCriterias`, object/field IDs), sources with + // `externalObjectDetail.id` + connection details, `entityClass` string. + const federatedRaw = { + id: ENTITY_TEST_CONSTANTS.ENTITY_ID, + name: "SalesforceAccount", + displayName: "Salesforce Account", + description: "", + isRbacEnabled: false, + isInsightsEnabled: false, + entityClass: EntityClass.Federated, + fields: [], + externalFields: [ + { + externalObjectDetail: { id: "obj-account", externalObjectName: "Account", externalConnectionId: "extconn-account", primaryKey: "Id", isPrimarySource: true, method: '{"GET":{}}' }, + externalConnectionDetail: { connectionId: "conn-sf", elementInstanceId: 357401, folderKey: "folder-sf", connectorKey: "uipath-salesforce-sfdc" }, + fields: [ + { fieldDefinition: { name: "IdField", displayName: "IdField", isPrimaryKey: false, sqlType: { name: "NVARCHAR", lengthLimit: 512 } }, externalFieldMappingDetail: { externalFieldName: "Id", externalFieldType: "string", directionType: DataDirectionType.ReadOnly, sortable: true } }, + ], + }, + { + externalObjectDetail: { id: "obj-invoice", externalObjectName: "Invoice", isPrimarySource: false }, + nativeConnectionDetail: { entityId: "conn-native", folderKey: "folder-native" }, + fields: [ + { fieldDefinition: { name: "invoiceId", displayName: "invoiceId", isPrimaryKey: false, sqlType: { name: "NVARCHAR", lengthLimit: 512 } }, externalFieldMappingDetail: { externalFieldName: "invoiceId", externalFieldType: "text", directionType: DataDirectionType.ReadOnly, sortable: false } }, + ], + }, + ], + sourceJoinCriterias: [ + { id: "join-1", entityId: ENTITY_TEST_CONSTANTS.ENTITY_ID, sourceObjectId: "obj-account", joinFieldName: "Id", joinType: JoinType.LeftJoin, relatedSourceObjectId: "obj-invoice", relatedSourceFieldName: "invoiceId" }, + ], + }; + + beforeEach(() => { + mockApiClient.get.mockResolvedValue(federatedRaw); + mockApiClient.post.mockResolvedValue(undefined); + }); + + it("should preserve external sources and translate joins when updating native fields (bug fix: don't drop joins)", async () => { + await entityService.updateById(ENTITY_TEST_CONSTANTS.ENTITY_ID, { + addFields: [{ name: "note", type: EntityFieldDataType.STRING }], + }); + + const def = mockApiClient.post.mock.calls[0][1].entityDefinition; + expect(def.entityClassId).toBe(EntityClassId.Federated); + expect(def.externalFields).toHaveLength(2); + expect(def.externalFields[0].externalObjectDetail.externalObjectName).toBe("Account"); + // The GET omits fieldDisplayType; the repost must default it (the upsert requires it). + expect(def.externalFields[0].fields[0].fieldDefinition.fieldDisplayType).toBe(FieldDisplayType.Basic); + // sourceJoinCriterias (id-based) → sourceJoinConditionDetails (names + conn ids), + // joinType string passed through, native source connId = its entityId. + expect(def.sourceJoinConditionDetails).toEqual([ + { + sourceObjectName: "Account", + sourceJoinField: "Id", + sourceObjectConnectionId: "conn-sf", + joinType: JoinType.LeftJoin, // passed through from the read shape (string form accepted) + relatedSourceObjectName: "Invoice", + relatedSourceJoinField: "invoiceId", + relatedSourceObjectConnectionId: "conn-native", + }, + ]); + }); + + it("should append a join via addSourceJoins (existing join preserved)", async () => { + await entityService.updateById(ENTITY_TEST_CONSTANTS.ENTITY_ID, { + addSourceJoins: [ + { + sourceObjectName: "Account", + sourceJoinField: "OwnerId", + sourceObjectConnectionId: "conn-sf", + joinType: JoinType.LeftJoin, + relatedSourceObjectName: "Invoice", + relatedSourceJoinField: "ownerId", + relatedSourceObjectConnectionId: "conn-native", + }, + ], + }); + + const def = mockApiClient.post.mock.calls[0][1].entityDefinition; + expect(def.sourceJoinConditionDetails).toHaveLength(2); + expect(def.sourceJoinConditionDetails[0].sourceJoinField).toBe("Id"); + expect(def.sourceJoinConditionDetails[1].sourceJoinField).toBe("OwnerId"); + }); + + it("should add a new external source via addExternalSources", async () => { + await entityService.updateById(ENTITY_TEST_CONSTANTS.ENTITY_ID, { + addExternalSources: [ + { + externalConnectionDetail: { connectionId: "conn-sf", elementInstanceId: 357401, folderKey: "folder-sf", connectorKey: "uipath-salesforce-sfdc", connectorName: "Salesforce" }, + externalObjectDetail: { externalObjectName: "Contact", primaryKey: "Id", method: "{}" }, + fields: [ + { field: { name: "ContactName", type: EntityFieldDataType.STRING }, externalFieldMappingDetail: { externalFieldName: "Name", externalFieldType: "string", directionType: DataDirectionType.ReadOnly } }, + ], + }, + ], + }); + + const def = mockApiClient.post.mock.calls[0][1].entityDefinition; + expect(def.externalFields).toHaveLength(3); + expect(def.externalFields[2].externalObjectDetail.externalObjectName).toBe("Contact"); + expect(def.externalFields[2].fields[0].fieldDefinition.name).toBe("ContactName"); + }); + + it("should remove a field from a source", async () => { + await entityService.updateById(ENTITY_TEST_CONSTANTS.ENTITY_ID, { + removeFieldsFromSource: [{ sourceObjectName: "Account", fieldNames: ["IdField"] }], + }); + + const def = mockApiClient.post.mock.calls[0][1].entityDefinition; + const account = def.externalFields.find((s: { externalObjectDetail?: { externalObjectName?: string } }) => s.externalObjectDetail?.externalObjectName === "Account"); + expect(account.fields).toHaveLength(0); + }); + + it("should update an existing join in place via updateSourceJoin", async () => { + await entityService.updateById(ENTITY_TEST_CONSTANTS.ENTITY_ID, { + updateSourceJoin: [{ sourceObjectName: "Account", relatedSourceObjectName: "Invoice", sourceJoinField: "AltKey" }], + }); + + const def = mockApiClient.post.mock.calls[0][1].entityDefinition; + expect(def.sourceJoinConditionDetails).toHaveLength(1); + expect(def.sourceJoinConditionDetails[0].sourceJoinField).toBe("AltKey"); + // Untouched fields on the join are preserved. + expect(def.sourceJoinConditionDetails[0].relatedSourceJoinField).toBe("invoiceId"); + }); + + it("should cascade-remove joins when a source is removed (a join can't outlive its source)", async () => { + await entityService.updateById(ENTITY_TEST_CONSTANTS.ENTITY_ID, { + removeExternalSources: ["Invoice"], + }); + + const def = mockApiClient.post.mock.calls[0][1].entityDefinition; + expect(def.externalFields.map((s: { externalObjectDetail?: { externalObjectName?: string } }) => s.externalObjectDetail?.externalObjectName)).toEqual(["Account"]); + // The Account→Invoice join is dropped automatically — no separate removeSourceJoins needed. + expect(def.sourceJoinConditionDetails).toBeUndefined(); + }); + + it("should add a field to an existing source via addFieldsToSource", async () => { + await entityService.updateById(ENTITY_TEST_CONSTANTS.ENTITY_ID, { + addFieldsToSource: [ + { + sourceObjectName: "Account", + fields: [ + { + field: { name: "Phone", type: EntityFieldDataType.STRING }, + externalFieldMappingDetail: { externalFieldName: "Phone", directionType: DataDirectionType.ReadOnly }, + }, + ], + }, + ], + }); + + const def = mockApiClient.post.mock.calls[0][1].entityDefinition; + const account = def.externalFields.find((s: { externalObjectDetail?: { externalObjectName?: string } }) => s.externalObjectDetail?.externalObjectName === "Account"); + const names = account.fields.map((f: { fieldDefinition?: { name?: string } }) => f.fieldDefinition?.name); + expect(names).toContain("IdField"); + expect(names).toContain("Phone"); + }); + + it("should update a field's mapping in place via updateExternalFieldMapping", async () => { + await entityService.updateById(ENTITY_TEST_CONSTANTS.ENTITY_ID, { + updateExternalFieldMapping: [ + { sourceObjectName: "Account", fieldName: "IdField", mapping: { sortable: false } }, + ], + }); + + const def = mockApiClient.post.mock.calls[0][1].entityDefinition; + const account = def.externalFields.find((s: { externalObjectDetail?: { externalObjectName?: string } }) => s.externalObjectDetail?.externalObjectName === "Account"); + const idField = account.fields.find((f: { fieldDefinition?: { name?: string } }) => f.fieldDefinition?.name === "IdField"); + // Existing mapping keys are preserved; only the supplied key changes. + expect(idField.externalFieldMappingDetail.sortable).toBe(false); + expect(idField.externalFieldMappingDetail.externalFieldName).toBe("Id"); + }); + + it("should throw when addFieldsToSource targets a source that doesn't exist", async () => { + await expect( + entityService.updateById(ENTITY_TEST_CONSTANTS.ENTITY_ID, { + addFieldsToSource: [ + { + sourceObjectName: "Nonexistent", + fields: [ + { + field: { name: "X", type: EntityFieldDataType.STRING }, + externalFieldMappingDetail: { externalFieldName: "X", directionType: DataDirectionType.ReadOnly }, + }, + ], + }, + ], + }), + ).rejects.toThrow(/source 'Nonexistent' not found/); + expect(mockApiClient.post).not.toHaveBeenCalled(); + }); + + it("should throw when removeFieldsFromSource targets a source that doesn't exist", async () => { + await expect( + entityService.updateById(ENTITY_TEST_CONSTANTS.ENTITY_ID, { + removeFieldsFromSource: [{ sourceObjectName: "Nonexistent", fieldNames: ["X"] }], + }), + ).rejects.toThrow(/source 'Nonexistent' not found/); + expect(mockApiClient.post).not.toHaveBeenCalled(); + }); + + it("should throw when updateExternalFieldMapping targets a field that doesn't exist", async () => { + await expect( + entityService.updateById(ENTITY_TEST_CONSTANTS.ENTITY_ID, { + updateExternalFieldMapping: [ + { sourceObjectName: "Account", fieldName: "Nonexistent", mapping: { sortable: true } }, + ], + }), + ).rejects.toThrow(/field 'Nonexistent' not found on source 'Account'/); + expect(mockApiClient.post).not.toHaveBeenCalled(); + }); + + it("should throw a source-not-found (not field-not-found) error when updateExternalFieldMapping targets a missing source", async () => { + await expect( + entityService.updateById(ENTITY_TEST_CONSTANTS.ENTITY_ID, { + updateExternalFieldMapping: [ + { sourceObjectName: "Nonexistent", fieldName: "AnyField", mapping: { sortable: true } }, + ], + }), + ).rejects.toThrow(/source 'Nonexistent' not found/); + expect(mockApiClient.post).not.toHaveBeenCalled(); + }); + + it("should throw when updateSourceJoin targets a join that doesn't exist", async () => { + await expect( + entityService.updateById(ENTITY_TEST_CONSTANTS.ENTITY_ID, { + updateSourceJoin: [{ sourceObjectName: "Account", relatedSourceObjectName: "Nonexistent", sourceJoinField: "X" }], + }), + ).rejects.toThrow(/no join between 'Account' and 'Nonexistent'/); + expect(mockApiClient.post).not.toHaveBeenCalled(); + }); + }); + it("should update field metadata in-place", async () => { mockApiClient.get.mockResolvedValue(mockRawEntity); mockApiClient.post.mockResolvedValue(undefined); @@ -4590,7 +4954,7 @@ describe("EntityService Unit Tests", () => { await entityService.updateById(ENTITY_TEST_CONSTANTS.ENTITY_ID, options); expect(mockApiClient.patch).toHaveBeenCalledWith( - DATA_FABRIC_ENDPOINTS.ENTITY.UPDATE( + DATA_FABRIC_ENDPOINTS.ENTITY.UPDATE_METADATA( ENTITY_TEST_CONSTANTS.ENTITY_ID, ), { @@ -4611,7 +4975,7 @@ describe("EntityService Unit Tests", () => { }); expect(mockApiClient.patch).toHaveBeenCalledWith( - DATA_FABRIC_ENDPOINTS.ENTITY.UPDATE( + DATA_FABRIC_ENDPOINTS.ENTITY.UPDATE_METADATA( ENTITY_TEST_CONSTANTS.ENTITY_ID, ), { displayName: ENTITY_TEST_CONSTANTS.ENTITY_DISPLAY_NAME }, @@ -4653,7 +5017,7 @@ describe("EntityService Unit Tests", () => { folderHeaders, ); expect(mockApiClient.patch).toHaveBeenCalledWith( - DATA_FABRIC_ENDPOINTS.ENTITY.UPDATE(ENTITY_TEST_CONSTANTS.ENTITY_ID), + DATA_FABRIC_ENDPOINTS.ENTITY.UPDATE_METADATA(ENTITY_TEST_CONSTANTS.ENTITY_ID), { displayName: "renamed" }, folderHeaders, ); @@ -4667,7 +5031,7 @@ describe("EntityService Unit Tests", () => { }); expect(mockApiClient.patch).toHaveBeenCalledWith( - DATA_FABRIC_ENDPOINTS.ENTITY.UPDATE( + DATA_FABRIC_ENDPOINTS.ENTITY.UPDATE_METADATA( ENTITY_TEST_CONSTANTS.ENTITY_ID, ), { isRbacEnabled: false }, @@ -4697,7 +5061,7 @@ describe("EntityService Unit Tests", () => { { headers: {} }, ); expect(mockApiClient.patch).toHaveBeenCalledWith( - DATA_FABRIC_ENDPOINTS.ENTITY.UPDATE( + DATA_FABRIC_ENDPOINTS.ENTITY.UPDATE_METADATA( ENTITY_TEST_CONSTANTS.ENTITY_ID, ), { displayName: "New Display Name" },