From 47435b77708311be6904e5d01d2827839455455e Mon Sep 17 00:00:00 2001 From: viktorsperling Date: Tue, 24 Mar 2026 13:51:39 +0100 Subject: [PATCH] feat(dts-generator): add bindContext declarations and tests to TypedJSONModel --- .../src/resources/typed-json-model.d.ts | 58 +++++++++++++++++ test-packages/typed-json-model/package.json | 2 +- .../typed-json-model/webapp/model/model.ts | 23 +++++++ .../test/cases/absolute-complex-inference.ts | 1 + .../webapp/model/test/cases/edgeCases.ts | 60 ++++++++++++++++++ .../typed-json-model/webapp/model/typing.ts | 34 ++++++++++ yarn.lock | 63 +++++-------------- 7 files changed, 191 insertions(+), 50 deletions(-) diff --git a/packages/dts-generator/src/resources/typed-json-model.d.ts b/packages/dts-generator/src/resources/typed-json-model.d.ts index 5d33d33..f87e50d 100644 --- a/packages/dts-generator/src/resources/typed-json-model.d.ts +++ b/packages/dts-generator/src/resources/typed-json-model.d.ts @@ -2,6 +2,7 @@ declare module "sap/ui/model/json/TypedJSONModel" { import JSONModel from "sap/ui/model/json/JSONModel"; import TypedJSONContext from "sap/ui/model/json/TypedJSONContext"; import Context from "sap/ui/model/Context"; + import ClientContextBinding from "sap/ui/model/ClientContextBinding"; /** * TypedJSONModel is a subclass of JSONModel that provides type-safe access to the model data. It is only available when using UI5 with TypeScript. @@ -18,6 +19,19 @@ declare module "sap/ui/model/json/TypedJSONModel" { fnCallBack?: Function, bReload?: boolean, ): TypedJSONContext; + bindContext>( + sPath: Path, + oContext?: undefined, + mParameters?: object, + ): ClientContextBinding; + bindContext< + Path extends RelativeObjectBindingPath, + Root extends AbsoluteObjectBindingPath, + >( + sPath: Path, + oContext?: TypedJSONContext, + mParameters?: object, + ): ClientContextBinding; getData(): Data; getProperty>( sPath: Path, @@ -82,6 +96,24 @@ declare module "sap/ui/model/json/TypedJSONModel" { : // if T is not of type object: never; + /** + * Valid absolute binding path for underlying object types (excludes arrays and primitives). + * + * @example + * type Order = { customer: { address: { city: string } }, items: string[], total: number }; + * type ObjectPaths = AbsoluteObjectBindingPath; // "/customer" | "/customer/address" + */ + export type AbsoluteObjectBindingPath = { + [Path in AbsoluteBindingPath]: PropertyByAbsoluteBindingPath< + Type, + Path + > extends Array + ? never + : PropertyByAbsoluteBindingPath extends object + ? Path + : never; + }[AbsoluteBindingPath]; + /** * Valid relative binding path in a JSONModel. * The root of the path is defined by the given root string. @@ -98,6 +130,32 @@ declare module "sap/ui/model/json/TypedJSONModel" { ? Rest : never; + /** + * Valid relative binding path for underlying object types (excludes arrays and primitives). + * The root of the path is defined by the given root string. + * + * @example + * type SalesOrder = { buyer: { id: string, name: string }, items: string[] }; + * type PathRelativeToSalesOrder = RelativeObjectBindingPath; // never (no nested objects) + * + * type Order = { customer: { address: { city: string } }, total: number }; + * type PathInOrder = RelativeObjectBindingPath; // "customer" | "customer/address" + */ + export type RelativeObjectBindingPath< + Type, + Root extends AbsoluteBindingPath, + > = { + [Path in RelativeBindingPath]: PropertyByRelativeBindingPath< + Type, + Root, + Path + > extends Array + ? never + : PropertyByRelativeBindingPath extends object + ? Path + : never; + }[RelativeBindingPath]; + /** * The type of a property in a JSONModel identified by the given path. * Counterpart to {@link AbsoluteBindingPath}. diff --git a/test-packages/typed-json-model/package.json b/test-packages/typed-json-model/package.json index 290cf06..dfc18d1 100644 --- a/test-packages/typed-json-model/package.json +++ b/test-packages/typed-json-model/package.json @@ -16,7 +16,7 @@ "ci": "npm run lint && npm run ui5lint && npm run ts-typecheck && npm run test" }, "devDependencies": { - "@types/openui5": "1.136.0", + "@openui5/types": "^1.146.0", "@ui5/cli": "^4.0.30", "@ui5/linter": "^1.20.2", "eslint": "^9.37.0", diff --git a/test-packages/typed-json-model/webapp/model/model.ts b/test-packages/typed-json-model/webapp/model/model.ts index a3b70ad..320e506 100644 --- a/test-packages/typed-json-model/webapp/model/model.ts +++ b/test-packages/typed-json-model/webapp/model/model.ts @@ -2,10 +2,13 @@ import Context from "sap/ui/model/Context"; import JSONModel from "sap/ui/model/json/JSONModel"; import { AbsoluteBindingPath, + AbsoluteObjectBindingPath, PropertyByAbsoluteBindingPath, PropertyByRelativeBindingPath, RelativeBindingPath, + RelativeObjectBindingPath, } from "./typing"; +import ClientContextBinding from "sap/ui/model/ClientContextBinding"; export class TypedJSONContext> extends Context { constructor(oModel: TypedJSONModel, sPath: Root) { @@ -39,6 +42,26 @@ export class TypedJSONModel extends JSONModel { return super.createBindingContext(sPath, oContext, mParameters, fnCallBack, bReload) as TypedJSONContext; } + // Overload for absolute paths + bindContext>( + sPath: Path, + oContext?: undefined, + mParameters?: object, + ): ClientContextBinding; + // Overload for relative paths + bindContext, Root extends AbsoluteObjectBindingPath>( + sPath: Path, + oContext?: TypedJSONContext, + mParameters?: object, + ): ClientContextBinding; + // Implementation + bindContext< + Path extends AbsoluteObjectBindingPath | RelativeObjectBindingPath, + Root extends AbsoluteObjectBindingPath, + >(sPath: Path, oContext?: TypedJSONContext, mParameters?: object): ClientContextBinding { + return super.bindContext(sPath, oContext, mParameters); + } + getData(): Data { return super.getData() as Data; } diff --git a/test-packages/typed-json-model/webapp/model/test/cases/absolute-complex-inference.ts b/test-packages/typed-json-model/webapp/model/test/cases/absolute-complex-inference.ts index 6a37cff..dd52717 100644 --- a/test-packages/typed-json-model/webapp/model/test/cases/absolute-complex-inference.ts +++ b/test-packages/typed-json-model/webapp/model/test/cases/absolute-complex-inference.ts @@ -12,6 +12,7 @@ import { JSONSafe, objectLikeByInference, Placeholder } from "../input"; import { TypedJSONModel } from "../../model"; +import ClientContextBinding from "sap/ui/model/ClientContextBinding"; /*********************************************************************************************************************** * Check model.setProperty diff --git a/test-packages/typed-json-model/webapp/model/test/cases/edgeCases.ts b/test-packages/typed-json-model/webapp/model/test/cases/edgeCases.ts index 43fe5e6..bc61d54 100644 --- a/test-packages/typed-json-model/webapp/model/test/cases/edgeCases.ts +++ b/test-packages/typed-json-model/webapp/model/test/cases/edgeCases.ts @@ -2,7 +2,9 @@ * @file Various edge cases to test the TypedJSONModel */ +import ClientContextBinding from "sap/ui/model/ClientContextBinding"; import { TypedJSONModel } from "../../model"; +import { Placeholder } from "../input"; /*********************************************************************************************************************** * Edge Case: The underlying data structure is an array (rather unusual) @@ -65,3 +67,61 @@ const model4 = new TypedJSONModel(edgeCase); /** @expect ok */ model4.setProperty("/anIntersection", { a: 1, b: "b" }); /** @expect ts2345 */ model4.setProperty("/anIntersection", { a: 1 }); /** @expect ts2322 */ model4.setProperty("/anIntersection", { a: 1, b: 2 }); + +/*********************************************************************************************************************** + * Check model.bindContext + **********************************************************************************************************************/ + +// Absolute paths +const data = { + aString: "string", + anObject: { a: "foo" }, + anArray: [], + anArrayOfObjects: [{ aNumber: 1 }], + aPlaceholder: new Placeholder(), + anArrayOfPlaceholders: [new Placeholder()], + aTuple: ["string", 1], +}; + +const model5 = new TypedJSONModel(data); + +/** @expect ok */ let clientContextBindingAbsolute: ClientContextBinding = model5.bindContext("/anObject"); +/** @expect ok */ model5.bindContext("/anArrayOfObjects/0"); +/** @expect ok */ model5.bindContext("/aPlaceholder"); +/** @expect ok */ model5.bindContext("/anArrayOfPlaceholders/0"); + +/** @expect ts2769 */ model5.bindContext("/anArray"); +/** @expect ts2769 */ model5.bindContext("/aTuple"); +/** @expect ts2769 */ model5.bindContext("/aTuple/0"); +/** @expect ts2769 */ model5.bindContext("/aJsonSafeArray/0"); +/** @expect ts2769 */ model5.bindContext("/anArrayOfObjects/0/aNumber"); +/** @expect ts2769 */ model5.bindContext("/anArray/0/doesNotExist"); + +// Relative paths + +const dataForRelativeCase = { + root: { + aString: "string", + anObject: { a: "foo" }, + anArray: [], + anArrayOfObjects: [{ aNumber: 1 }], + aPlaceholder: new Placeholder(), + anArrayOfPlaceholders: [new Placeholder()], + aTuple: ["string", 1], + }, +}; + +const model6 = new TypedJSONModel(dataForRelativeCase); +const context = model6.createBindingContext("/root"); + +/** @expect ok */ let clientContextBindingRelative: ClientContextBinding = model6.bindContext("anObject", context); +/** @expect ok */ model6.bindContext("anArrayOfObjects/0", context); +/** @expect ok */ model6.bindContext("aPlaceholder", context); +/** @expect ok */ model6.bindContext("anArrayOfPlaceholders/0", context); + +/** @expect ts2769 */ model6.bindContext("anArray", context); +/** @expect ts2769 */ model6.bindContext("aTuple", context); +/** @expect ts2769 */ model6.bindContext("aTuple/0", context); +/** @expect ts2769 */ model6.bindContext("aJsonSafeArray/0", context); +/** @expect ts2769 */ model6.bindContext("anArrayOfObjects/0/aNumber", context); +/** @expect ts2769 */ model6.bindContext("anArray/0/doesNotExist", context); diff --git a/test-packages/typed-json-model/webapp/model/typing.ts b/test-packages/typed-json-model/webapp/model/typing.ts index ba6d7f9..3aa3735 100644 --- a/test-packages/typed-json-model/webapp/model/typing.ts +++ b/test-packages/typed-json-model/webapp/model/typing.ts @@ -28,6 +28,21 @@ export type AbsoluteBindingPath = : // if T is not of type object: never; +/** + * Valid absolute binding path for underlying object types (excludes arrays and primitives). + * + * @example + * type Order = { customer: { address: { city: string } }, items: string[], total: number }; + * type ObjectPaths = AbsoluteObjectBindingPath; // "/customer" | "/customer/address" + */ +export type AbsoluteObjectBindingPath = { + [Path in AbsoluteBindingPath]: PropertyByAbsoluteBindingPath extends Array + ? never + : PropertyByAbsoluteBindingPath extends object + ? Path + : never; +}[AbsoluteBindingPath]; + /** * Valid relative binding path in a JSONModel. * The root of the path is defined by the given root string. @@ -87,6 +102,25 @@ export type PropertyByRelativeBindingPath< RelativePath extends string, > = PropertyByAbsoluteBindingPath; +/** + * Valid relative binding path for underlying object types (excludes arrays and primitives). + * The root of the path is defined by the given root string. + * + * @example + * type SalesOrder = { buyer: { id: string, name: string }, items: string[] }; + * type PathRelativeToSalesOrder = RelativeObjectBindingPath; // never (no nested objects) + * + * type Order = { customer: { address: { city: string } }, total: number }; + * type PathInOrder = RelativeObjectBindingPath; // "customer" | "customer/address" + */ +export type RelativeObjectBindingPath> = { + [Path in RelativeBindingPath]: PropertyByRelativeBindingPath extends Array + ? never + : PropertyByRelativeBindingPath extends object + ? Path + : never; +}[RelativeBindingPath]; + /*********************************************************************************************************************** * Helper types to split the types above into separate parts * to make it easier to read and understand. diff --git a/yarn.lock b/yarn.lock index 6d38843..dc503b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2361,6 +2361,14 @@ dependencies: "@octokit/openapi-types" "^24.2.0" +"@openui5/types@^1.146.0": + version "1.146.0" + resolved "https://registry.yarnpkg.com/@openui5/types/-/types-1.146.0.tgz#f3f035bc0f4e25f23acfeba888b927347fb3b336" + integrity sha512-6jzQ54BIpOQEL8+46u2WsaR9gFcjyEaKphUlAS82Pt60OOW8p2xpmTknFRRZDgBmP4IbZZob9nLb82MNGMRTSw== + dependencies: + "@types/jquery" "3.5.13" + "@types/qunit" "2.5.4" + "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" @@ -2811,14 +2819,6 @@ "@types/jquery" "~3.5.13" "@types/qunit" "^2.5.4" -"@types/openui5@1.136.0": - version "1.136.0" - resolved "https://registry.yarnpkg.com/@types/openui5/-/openui5-1.136.0.tgz#9cada8f12d5d03d03f4975553cb763da2e7fa104" - integrity sha512-gdjK8/bYKsdZUiinARbYW+B6sbAVo0B4KLbHMs/noBzx2wfUoHOb9DiS+lpBpjtfD28NyiluReCp2R4kp7fceA== - dependencies: - "@types/jquery" "~3.5.13" - "@types/qunit" "^2.5.4" - "@types/qunit@2.5.4": version "2.5.4" resolved "https://registry.yarnpkg.com/@types/qunit/-/qunit-2.5.4.tgz#0518940acc6013259a8619a1ec34ce0e4ff8d1c4" @@ -3008,7 +3008,7 @@ yargs "^17.7.2" "@ui5/dts-generator@link:packages/dts-generator": - version "3.9.1" + version "3.10.1" dependencies: "@definitelytyped/dtslint" latest "@definitelytyped/eslint-plugin" latest @@ -10948,7 +10948,7 @@ string-length@^4.0.2: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -10966,15 +10966,6 @@ string-width@^1.0.1: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -11062,7 +11053,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -11076,13 +11067,6 @@ strip-ansi@^3.0.0, strip-ansi@^3.0.1: dependencies: ansi-regex "^2.0.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1, strip-ansi@^7.1.0: version "7.1.2" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" @@ -11561,7 +11545,7 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.2.tgz#5ebb5e5a5b75f085f22bc3f8460fba308310fa78" integrity sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w== -"typescript-5.3@npm:typescript@~5.3.0-0": +"typescript-5.3@npm:typescript@~5.3.0-0", typescript@5.3.3: version "5.3.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37" integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw== @@ -11591,7 +11575,7 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e" integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== -"typescript-5.9@npm:typescript@~5.9.0-0": +"typescript-5.9@npm:typescript@~5.9.0-0", typescript@5.9.3, "typescript@>=3 < 6", typescript@^5.9.3: version "5.9.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== @@ -11611,21 +11595,11 @@ typescript-eslint@^8.46.1: "@typescript-eslint/typescript-estree" "8.46.1" "@typescript-eslint/utils" "8.46.1" -typescript@5.3.3: - version "5.3.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37" - integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw== - typescript@5.8.2: version "5.8.2" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.2.tgz#8170b3702f74b79db2e5a96207c15e65807999e4" integrity sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ== -typescript@5.9.3, "typescript@>=3 < 6", typescript@^5.9.3: - version "5.9.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" - integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== - uc.micro@^2.0.0, uc.micro@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee" @@ -12075,7 +12049,7 @@ workerpool@^9.2.0, workerpool@^9.3.4: resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-9.3.4.tgz#f6c92395b2141afd78e2a889e80cb338fe9fca41" integrity sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -12093,15 +12067,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"