├── .github └── workflows │ ├── ci.yml │ ├── release.yml │ └── reuse.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── LICENSES └── Apache-2.0.txt ├── README.md ├── REUSE.toml ├── _assets ├── AssociationChange.png ├── AssociationID.png ├── ChainedAssociationChange.png ├── ChainedAssociationID.png ├── CompositionChange.png ├── CustomTypeChange.png ├── CustomTypeID.png ├── ObjectID.png ├── UnionChange.png ├── changes-custom-wbox.png ├── changes-custom.png ├── changes-id-hr-wbox.png ├── changes-id-wbox.png ├── changes-type-hr-wbox.png ├── changes-type-wbox.png ├── changes-value-hr-wbox.png ├── changes-value-wbox.png ├── changes-wbox.png └── changes.png ├── _i18n ├── i18n.properties ├── i18n_de.properties ├── i18n_en.properties ├── i18n_es.properties ├── i18n_fr.properties ├── i18n_it.properties ├── i18n_ja.properties ├── i18n_pl.properties ├── i18n_pt.properties ├── i18n_ru.properties └── i18n_zh_CN.properties ├── cds-plugin.js ├── eslint.config.mjs ├── index.cds ├── lib ├── change-log.js ├── entity-helper.js ├── localization.js └── template-processor.js ├── package.json └── tests ├── bookshop ├── db │ ├── _i18n │ │ └── i18n.properties │ ├── codelists.cds │ ├── common │ │ ├── codeLists.cds │ │ └── types.cds │ ├── data │ │ ├── sap.capire.bookshop-ActivationStatusCode.csv │ │ ├── sap.capire.bookshop-AssocOne.csv │ │ ├── sap.capire.bookshop-AssocThree.csv │ │ ├── sap.capire.bookshop-AssocTwo.csv │ │ ├── sap.capire.bookshop-Authors.csv │ │ ├── sap.capire.bookshop-BookStoreRegistry.csv │ │ ├── sap.capire.bookshop-BookStores.bookInventory.csv │ │ ├── sap.capire.bookshop-BookStores.csv │ │ ├── sap.capire.bookshop-Books.csv │ │ ├── sap.capire.bookshop-Books_texts.csv │ │ ├── sap.capire.bookshop-City.csv │ │ ├── sap.capire.bookshop-Country.csv │ │ ├── sap.capire.bookshop-Customers.csv │ │ ├── sap.capire.bookshop-Genres.csv │ │ ├── sap.capire.bookshop-Level1Entity.csv │ │ ├── sap.capire.bookshop-Level1Object.csv │ │ ├── sap.capire.bookshop-Level2Entity.csv │ │ ├── sap.capire.bookshop-Level2Object.csv │ │ ├── sap.capire.bookshop-Level3Entity.csv │ │ ├── sap.capire.bookshop-Level3Object.csv │ │ ├── sap.capire.bookshop-Order.Items.csv │ │ ├── sap.capire.bookshop-Order.csv │ │ ├── sap.capire.bookshop-OrderHeader.csv │ │ ├── sap.capire.bookshop-OrderItem.csv │ │ ├── sap.capire.bookshop-OrderItemNote.csv │ │ ├── sap.capire.bookshop-PaymentAgreementStatusCodes.csv │ │ ├── sap.capire.bookshop-Report.csv │ │ ├── sap.capire.bookshop-RootEntity.csv │ │ ├── sap.capire.bookshop-RootObject.csv │ │ ├── sap.capire.bookshop-Schools.classes.csv │ │ ├── sap.capire.bookshop-Schools.csv │ │ ├── sap.capire.bookshop-Volumns.csv │ │ ├── sap.capire.common.codelists-BookTypeCodes.csv │ │ ├── sap.capire.common.codelists-BookTypeCodes_texts.csv │ │ ├── sap.capire.common.codelists-LifecycleStatusCodes.csv │ │ └── sap.capire.common.codelists-LifecycleStatusCodes_texts.csv │ └── schema.cds ├── package.json └── srv │ ├── admin-service.cds │ ├── admin-service.js │ └── cat-service.cds ├── integration ├── fiori-draft-disabled.test.js ├── fiori-draft-enabled.test.js └── service-api.test.js ├── unit └── util.test.js └── utils └── api.js /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [ main ] 7 | pull_request: 8 | branches: [ main ] 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | node-version: [20.x, 18.x] 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v2 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | - run: npm i -g @sap/cds-dk 24 | - run: npm i 25 | - run: cds v 26 | - run: npm run lint 27 | - run: npm run test 28 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | permissions: 7 | contents: write 8 | 9 | jobs: 10 | publish-npm: 11 | runs-on: ubuntu-latest 12 | environment: npm 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: 18 18 | registry-url: https://registry.npmjs.org/ 19 | - name: run tests 20 | run: | 21 | npm i -g @sap/cds-dk 22 | npm i 23 | npm run lint 24 | npm run test 25 | - name: get version 26 | id: package-version 27 | uses: martinbeentjes/npm-get-version-action@v1.2.3 28 | - name: parse changelog 29 | id: parse-changelog 30 | uses: schwma/parse-changelog-action@v1.0.0 31 | with: 32 | version: '${{ steps.package-version.outputs.current-version }}' 33 | - name: create a GitHub release 34 | uses: ncipollo/release-action@v1 35 | with: 36 | tag: 'v${{ steps.package-version.outputs.current-version }}' 37 | body: '${{ steps.parse-changelog.outputs.body }}' 38 | - run: npm publish --access public 39 | env: 40 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 41 | -------------------------------------------------------------------------------- /.github/workflows/reuse.yml: -------------------------------------------------------------------------------- 1 | name: "Launch Metadata Creation Tool for REUSE" 2 | on: 3 | workflow_dispatch: ~ 4 | 5 | jobs: 6 | create_metadata_proposal: 7 | runs-on: ubuntu-latest 8 | name: "Metadata Creation Tool" 9 | steps: 10 | - uses: SAP/metadata-creation-tool-for-reuse@main 11 | with: 12 | repository_url: "${{ github.server_url }}/${{ github.repository }}" 13 | access_token: "${{ secrets.REUSE_ACCESS_TOKEN }}" 14 | copyright_owner: "SAP SE or an SAP affiliate company and contributors" 15 | upstream_contact: "The CAP team " -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | This project adheres to [Semantic Versioning](http://semver.org/). 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/). 6 | 7 | ## Version 1.0.9 - TBD 8 | 9 | ### Added 10 | 11 | - License entry 12 | 13 | ### Fixed 14 | 15 | - Handling of multiple records in one request 16 | 17 | ### Changed 18 | 19 | - prepare for CDS9 in tests 20 | 21 | ## Version 1.0.8 - 28.03.25 22 | 23 | ### Added 24 | 25 | - Added @UI.MultiLineText to value fields 26 | - Added support for Multi-Tenancy 27 | - Added configuration options to disable tracking of CREATE/UPDATE/DELETE operations on a project level 28 | 29 | ### Fixed 30 | 31 | - Handling of numeric and boolean fields was faulty, when an initial value of `0` for numeric or `false` for boolean was supplied 32 | - Decimal values were handled differently for HANA and SQlite 33 | - Missing UI Label for one attribute (`ChangeLog.ID`) of the Changes UI facet 34 | - Support for @UI.HeaderInfo.TypeName as fallback for the UI Label of the key 35 | - Compilation error when an association is used as a key 36 | - Fixed handling of unmanaged composition of many 37 | - Proper casing of the operation enum type 38 | 39 | 40 | ### Changed 41 | 42 | - Added warning and mitigation for multi-tenant deployments with MTX 43 | - Added a disclaimer of upcoming new version having a minimum requirement of CDS 8.6 for multitenancy fix 44 | - Changed the default limit on non-HANA databases from 255 to 5000 characters for all String values 45 | - Updated peer dependency from CDS7 to CDS8 46 | 47 | 48 | ## Version 1.0.7 - 20.08.24 49 | 50 | ### Added 51 | 52 | - A global switch to preserve change logs for deleted data 53 | - For hierarchical entities, a method to determine their structure and a flag to indicate whether it is a root entity was introduced. For child entities, information about the parent is recorded. 54 | 55 | 56 | ### Fixed 57 | 58 | - CDS 8 does not support queries for draft-enabled entities on the application service anymore. This was causing: SqliteError: NOT NULL constraint failed: (...).DraftAdministrativeData_DraftUUID 59 | - CDS 8 deprecated cds.transaction, causing change logs of nested documents to be wrong, replaced with req.event 60 | - CDS 8 rejects all direct CRUD requests for auto-exposed Compositions in non-draft cases. This was affecting test cases, since the ChangeView falls into this category 61 | - req._params and req.context are not official APIs and stopped working with CDS 8, replaced with official APIs 62 | - When running test cases in CDS 8, some requests failed with a status code of 404 63 | - ServiceEntity is not captured in the ChangeLog table in some cases 64 | - When modeling an inline entity, a non-existent association and parent ID was recorded 65 | - Fixed handling, when reqData was undefined 66 | 67 | ### Changed 68 | 69 | - Peer dependency to @sap/cds changed to ">=7" 70 | - Data marked as personal data using data privacy annotations won't get change-tracked anymore to satisfy product standards 71 | - Restructured Documentation 72 | 73 | 74 | ## Version 1.0.6 - 29.04.24 75 | 76 | ### Fixed 77 | 78 | - Storage of wrong ObjectID in some special scenarios 79 | - Missing localization of managed fields 80 | - Views without keys won't get the association and UI facet pushed anymore 81 | 82 | ### Added 83 | 84 | - A method to disable automatic generation of the UI Facet 85 | 86 | ### Changed 87 | 88 | - Improved documentation of the @changelog Annotation 89 | 90 | ## Version 1.0.5 - 15.01.24 91 | 92 | ### Fixed 93 | 94 | - Error on HANA when logging Boolean or Numeric Data 95 | 96 | ## Version 1.0.4 - 08.01.24 97 | 98 | ### Added 99 | 100 | - Side effect annotation now allows automatic refresh after a custom action caused changes 101 | 102 | ### Changed 103 | 104 | - Added a check to disable change tracking for views with a UNION 105 | 106 | ### Fixed 107 | 108 | - Handling of associations within change tracked entities 109 | - Handling of change log when custom actions on child entities are called 110 | 111 | ## Version 1.0.3 - 10.11.23 112 | 113 | ### Added 114 | 115 | - Added note about using `SAPUI5 v1.120.0` or later for proper lazy loading of the *Change History* table. 116 | - In README, add warning about tracking personal data. 117 | 118 | ### Changed 119 | 120 | - Support cases where parent/child entries are created simultaneously. 121 | - Allow for lazy loading of change history table (with SAP UI5 release 1.120.0). 122 | 123 | ## Version 1.0.2 - 31.10.23 124 | 125 | ### Changed 126 | 127 | - In README, use view of the full change-tracking table instead of the customized one for the main image. 128 | 129 | ## Version 1.0.1 - 26.10.23 130 | 131 | ### Changed 132 | 133 | - Flattened README structure. 134 | 135 | ### Fixed 136 | 137 | - Labels are looked up from the service entity (not the db entity only). 138 | 139 | ## Version 1.0.0 - 18.10.23 140 | 141 | ### Added 142 | 143 | - Initial release 144 | 145 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Code of Conduct 4 | 5 | All members of the project community must abide by the [SAP Open Source Code of Conduct](https://github.com/SAP/.github/blob/main/CODE_OF_CONDUCT.md). 6 | Only by respecting each other we can develop a productive, collaborative community. 7 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting [a project maintainer](.reuse/dep5). 8 | 9 | ## Engaging in Our Project 10 | 11 | We use GitHub to manage reviews of pull requests. 12 | 13 | * If you are a new contributor, see: [Steps to Contribute](#steps-to-contribute) 14 | 15 | * Before implementing your change, create an issue that describes the problem you would like to solve or the code that should be enhanced. Please note that you are willing to work on that issue. 16 | 17 | * The team will review the issue and decide whether it should be implemented as a pull request. In that case, they will assign the issue to you. If the team decides against picking up the issue, the team will post a comment with an explanation. 18 | 19 | ## Steps to Contribute 20 | 21 | Should you wish to work on an issue, please claim it first by commenting on the GitHub issue that you want to work on. This is to prevent duplicated efforts from other contributors on the same issue. 22 | 23 | If you have questions about one of the issues, please comment on them, and one of the maintainers will clarify. 24 | 25 | ## Contributing Code or Documentation 26 | 27 | You are welcome to contribute code in order to fix a bug or to implement a new feature that is logged as an issue. 28 | 29 | The following rule governs code contributions: 30 | 31 | * Contributions must be licensed under the [Apache 2.0 License](./LICENSE) 32 | * Due to legal reasons, contributors will be asked to accept a Developer Certificate of Origin (DCO) when they create the first pull request to this project. This happens in an automated fashion during the submission process. SAP uses [the standard DCO text of the Linux Foundation](https://developercertificate.org/). 33 | 34 | ## Issues and Planning 35 | 36 | * We use GitHub issues to track bugs and enhancement requests. 37 | 38 | * Please provide as much context as possible when you open an issue. The information you provide must be comprehensive enough to reproduce that issue for the assignee. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /LICENSES/Apache-2.0.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. 10 | 11 | "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. 12 | 13 | "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. 14 | 15 | "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. 16 | 17 | "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. 18 | 19 | "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. 20 | 21 | "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). 22 | 23 | "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. 24 | 25 | "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." 26 | 27 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 28 | 29 | 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 30 | 31 | 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 32 | 33 | 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: 34 | 35 | (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and 36 | 37 | (b) You must cause any modified files to carry prominent notices stating that You changed the files; and 38 | 39 | (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and 40 | 41 | (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. 42 | 43 | You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 44 | 45 | 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 46 | 47 | 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 48 | 49 | 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 50 | 51 | 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 52 | 53 | 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. 54 | 55 | END OF TERMS AND CONDITIONS 56 | 57 | APPENDIX: How to apply the Apache License to your work. 58 | 59 | To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. 60 | 61 | Copyright [yyyy] [name of copyright owner] 62 | 63 | Licensed under the Apache License, Version 2.0 (the "License"); 64 | you may not use this file except in compliance with the License. 65 | You may obtain a copy of the License at 66 | 67 | http://www.apache.org/licenses/LICENSE-2.0 68 | 69 | Unless required by applicable law or agreed to in writing, software 70 | distributed under the License is distributed on an "AS IS" BASIS, 71 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 72 | See the License for the specific language governing permissions and 73 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Change Tracking Plugin for SAP Cloud Application Programming Model (CAP) 2 | 3 | a [CDS plugin](https://cap.cloud.sap/docs/node.js/cds-plugins#cds-plugin-packages) for automatic capturing, storing, and viewing of the change records of modeled entities 4 | 5 | [![REUSE status](https://api.reuse.software/badge/github.com/cap-js/change-tracking)](https://api.reuse.software/info/github.com/cap-js/change-tracking) 6 | 7 | > [!IMPORTANT] 8 | > Following the CAP best practices, the new release now requires CDS8 as minimum version in the peer dependencies. If you want to use the plugin with an older version of CAP (CDS7), you will need to manually update the peer dependency in `package.json`. Please be aware that there will be no support for this version of the plugin with a CDS version below 8! 9 | 10 | > [!IMPORTANT] 11 | > This release establishes support for multi-tenant deployments using MTX and extensibility. 12 | > 13 | > To achieve this, the code was modified significantly. While we tested extensively, there still may be glitches or unexpected situations which we did not cover. So please **test this release extensively before applying it to productive** scenarios. Please also report any bugs or glitches, ideally by contributing a test-case for us to incorporate. 14 | > 15 | > See the changelog for a full list of changes 16 | 17 | > [!Warning] 18 | > 19 | > Please note that if your project is multi-tenant, then the CDS version must be higher than 8.6 and the mtx version higher than 2.5 for change-tracking to work. 20 | 21 | > [!Warning] 22 | > 23 | > When using multi-tenancy with MTX, the generated facets and associations have to be created by the model provider of the MTX component. Therefore, the plugin also must be added to the `package.json` of the MTX sidecar. 24 | >Although we tested this scenario extensively, there still might be cases where the automatic generation will not work as expected. If this happends in your scenario, we suggest using the `@changelog.disable_assoc` ([see here](#disable-association-to-changes-generation)) for all tracked entities and to add the association and facet manually to the service entity. 25 | 26 | 27 | ### Table of Contents 28 | 29 | - [Try it Locally](#try-it-locally) 30 | - [Detailed Explanation](#detailed-explanation) 31 | - [Human-readable Types and Fields](#human-readable-types-and-fields) 32 | - [Human-readable IDs](#human-readable-ids) 33 | - [Human-readable Values](#human-readable-values) 34 | - [Advanced Options](#advanced-options) 35 | - [Altered Table View](#altered-table-view) 36 | - [Disable Lazy Loading](#disable-lazy-loading) 37 | - [Disable UI Facet generation](#disable-ui-facet-generation) 38 | - [Disable Association to Changes Generation](#disable-association-to-changes-generation) 39 | - [Examples](#examples) 40 | - [Specify Object ID](#specify-object-id) 41 | - [Tracing Changes](#tracing-changes) 42 | - [Don'ts](#donts) 43 | - [Contributing](#contributing) 44 | - [Code of Conduct](#code-of-conduct) 45 | - [Licensing](#licensing) 46 | 47 | ## Try it Locally 48 | 49 | In this guide, we use the [Incidents Management reference sample app](https://github.com/cap-js/incidents-app) as the base to add change tracking to. 50 | 51 | 1. [Prerequisites](#1-prerequisites) 52 | 2. [Setup](#2-setup) 53 | 3. [Annotations](#3-annotations) 54 | 4. [Testing](#4-testing) 55 | 56 | ### 1. Prerequisites 57 | 58 | Clone the repository and apply the step-by-step instructions: 59 | 60 | ```sh 61 | git clone https://github.com/cap-js/incidents-app 62 | cd incidents-app 63 | npm i 64 | ``` 65 | 66 | **Alternatively**, you can clone the incidents app including the prepared enhancements for change-tracking: 67 | 68 | ```sh 69 | git clone https://github.com/cap-js/calesi --recursive 70 | cd calesi 71 | npm i 72 | ``` 73 | 74 | ```sh 75 | cds w samples/change-tracking 76 | ``` 77 | 78 | ### 2. Setup 79 | 80 | To enable change tracking, simply add this self-configuring plugin package to your project: 81 | 82 | ```sh 83 | npm add @cap-js/change-tracking 84 | ``` 85 | If you use multi-tenancy, please add the plugin also to the MTX poroject(The mtx version must be higher than 2.5). 86 | 87 | ### 3. Annotations 88 | 89 | > [!WARNING] 90 | > Please be aware that [**sensitive** or **personal** data](https://cap.cloud.sap/docs/guides/data-privacy/annotations#annotating-personal-data) (annotated with `@PersonalData`) is not change tracked, since viewing the log allows users to circumvent [audit-logging](https://cap.cloud.sap/docs/guides/data-privacy/audit-logging#setup). 91 | 92 | All we need to do is to identify what should be change-tracked by annotating respective entities and elements in our model with the `@changelog` annotation. Following the [best practice of separation of concerns](https://cap.cloud.sap/docs/guides/domain-modeling#separation-of-concerns), we do so in a separate file _srv/change-tracking.cds_: 93 | 94 | ```cds 95 | using { ProcessorService } from './processor-service'; 96 | 97 | annotate ProcessorService.Incidents { 98 | customer @changelog: [customer.name]; 99 | title @changelog; 100 | status @changelog; 101 | } 102 | 103 | annotate ProcessorService.Conversations with @changelog: [author, timestamp] { 104 | message @changelog @Common.Label: 'Message'; 105 | } 106 | ``` 107 | 108 | The minimal annotation we require for change tracking is `@changelog` on elements, as for the elements `title` and `status` in the sample snippet above. 109 | 110 | Additional identifiers or labels can be added to obtain more *human-readable* change records as described below. 111 | 112 | ### 4. Testing 113 | 114 | With the steps above, we have successfully set up change tracking for our reference application. Let's see that in action. 115 | 116 | 1. **Start the server**: 117 | 118 | ```sh 119 | cds watch 120 | ``` 121 | 122 | 2. **Make a change** on your change-tracked elements. This change will automatically be persisted in the database table (`sap.changelog.ChangeLog`) and made available in a pre-defined view, namely the [Change History view](#change-history-view) for your convenience. 123 | 124 | #### Change History View 125 | 126 | > [!IMPORTANT] 127 | > To ensure proper lazy loading of the Change History table, please use **SAPUI5 version 1.120.0** or higher.`
` 128 | > If you wish to *disable* this feature, please see the customization section on how to [disable lazy loading](#disable-lazy-loading). 129 | 130 | change-history 131 | 132 | If you have a Fiori Element application, the CDS plugin automatically provides and generates a view `sap.changelog.ChangeView`, the facet of which is automatically added to the Fiori Object Page of your change-tracked entities/elements. In the UI, this corresponds to the *Change History* table which serves to help you to view and search the stored change records of your modeled entities. 133 | 134 | ## Detailed Explanation 135 | 136 | ### Human-readable Types and Fields 137 | 138 | By default the implementation looks up *Object Type* names or *Field* names from respective `@title` or `@Common.Label` annotations, and applies i18n lookups. If no such annotations are given, the technical names of the respective CDS definitions are displayed. 139 | 140 | For example, without the `@title` annotation, changes to conversation entries would show up with the technical entity name: 141 | 142 | change-history-type 143 | 144 | With an annotation, and possible i18n translations like so: 145 | 146 | ```cds 147 | annotate Conversations with @title: 'Conversations'; 148 | ``` 149 | 150 | We get a human-readable display for *Object Type*: 151 | 152 | change-history-type-hr 153 | 154 | ### Human-readable IDs 155 | 156 | The changelog annotations for *Object ID* are defined at entity level. 157 | 158 | These are already human-readable by default, unless the `@changelog` definition cannot be uniquely mapped such as types `enum` or `Association`. 159 | 160 | For example, having a `@changelog` annotation without any additional identifiers, changes to conversation entries would show up as simple entity IDs: 161 | 162 | change-history-id 163 | 164 | However, this is not advisable as we cannot easily distinguish between changes. It is more appropriate to annotate as follows: 165 | 166 | ```cds 167 | annotate ProcessorService.Conversations with @changelog: [author, timestamp] { 168 | ``` 169 | 170 | change-history-id-hr 171 | 172 | Expanding the changelog annotation by additional identifiers `[author, timestamp]`, we can now better identify the `message` change events by their respective author and timestamp. 173 | 174 | ### Human-readable Values 175 | 176 | The changelog annotations for *New Value* and *Old Value* are defined at element level. 177 | 178 | They are already human-readable by default, unless the `@changelog` definition cannot be uniquely mapped such as types `enum` or `Association`. 179 | 180 | For example, having a `@changelog` annotation without any additional identifiers, changes to incident customer would show up as UUIDs: 181 | 182 | ```cds 183 | customer @changelog; 184 | ``` 185 | 186 | change-history-value 187 | 188 | Hence, here it is essential to add a unique identifier to obtain human-readable value columns: 189 | 190 | ```cds 191 | customer @changelog: [customer.name]; 192 | ``` 193 | 194 | change-history-value-hr 195 | 196 | ## Advanced Options 197 | 198 | ### Altered table view 199 | 200 | The *Change History* view can be easily adapted and configured to your own needs by simply changing or extending it. For example, let's assume we only want to show the first 5 columns in equal spacing, we would extend `srv/change-tracking.cds` as follows: 201 | 202 | ```cds 203 | using from '@cap-js/change-tracking'; 204 | 205 | annotate sap.changelog.ChangeView with @( 206 | UI.LineItem : [ 207 | { Value: modification, @HTML5.CssDefaults: { width:'20%' }}, 208 | { Value: createdAt, @HTML5.CssDefaults: { width:'20%' }}, 209 | { Value: createdBy, @HTML5.CssDefaults: { width:'20%' }}, 210 | { Value: entity, @HTML5.CssDefaults: { width:'20%' }}, 211 | { Value: objectID, @HTML5.CssDefaults: { width:'20%' }} 212 | ] 213 | ); 214 | ``` 215 | 216 | In the UI, the *Change History* table now contains 5 equally-spaced columns with the desired properties: 217 | 218 | change-history-custom 219 | 220 | For more information and examples on adding Fiori Annotations, see [Adding SAP Fiori Annotations](https://cap.cloud.sap/docs/advanced/fiori#fiori-annotations). 221 | 222 | ### Disable lazy loading 223 | 224 | To disable the lazy loading feature of the *Change History* table, you can add the following annotation to your `srv/change-tracking.cds`: 225 | 226 | ```cds 227 | using from '@cap-js/change-tracking'; 228 | 229 | annotate sap.changelog.aspect @(UI.Facets: [{ 230 | $Type : 'UI.ReferenceFacet', 231 | ID : 'ChangeHistoryFacet', 232 | Label : '{i18n>ChangeHistory}', 233 | Target: 'changes/@UI.PresentationVariant', 234 | ![@UI.PartOfPreview] 235 | }]); 236 | ``` 237 | 238 | The system now uses the SAPUI5 default setting `![@UI.PartOfPreview]: true`, such that the table will always shown when navigating to that respective Object page. 239 | 240 | ### Disable UI Facet generation 241 | 242 | If you do not want the UI facet added to a specific UI, you can annotate the service entity with `@changelog.disable_facet`. This will disable the automatic addition of the UI faced to this specific entity, but also all views or further projections up the chain. 243 | 244 | ### Disable Association to Changes Generation 245 | 246 | For some scenarios, e.g. when doing `UNION` and the `@changelog` annotion is still propageted, the automatic addition of the association to `changes` does not make sense. You can use `@changelog.disable_assoc`for this to be disabled on entity level. 247 | 248 | > [!IMPORTANT] 249 | > This will also supress the addition of the UI facet, since the change-view is not available as target entity anymore. 250 | 251 | ### Select types of changes to track 252 | 253 | If you do not want to track some types of changes, you can disable them using `disableCreateTracking`, `disableUpdateTracking` 254 | and `disableDeleteTracking` configs in your project settings: 255 | ```json 256 | { 257 | "cds": { 258 | "requires": { 259 | "change-tracking": { 260 | "disableCreateTracking": true, 261 | "disableUpdateTracking": false, 262 | "disableDeleteTracking": true 263 | } 264 | } 265 | } 266 | } 267 | ``` 268 | 269 | ### Preserve change logs of deleted data 270 | 271 | By default, deleting a record will also automatically delete all associated change logs. This helps reduce the impact on the size of the database. 272 | You can turn this behavior off globally by adding the following switch to the `package.json` of your project 273 | 274 | ``` 275 | ... 276 | "cds": { 277 | "requires": { 278 | ... 279 | "change-tracking": { 280 | "preserveDeletes": true 281 | } 282 | ... 283 | } 284 | } 285 | ... 286 | ``` 287 | > [!IMPORTANT] 288 | > Preserving the change logs of deleted data can have a significant impact on the size of the change logging table, since now such data also survives automated data retention runs. 289 | > You must implement an own **data retention strategy** for the change logging table in order to manage the size and performance of your database. 290 | 291 | ## Examples 292 | 293 | This section describes modelling cases for further reference, from simple to complex, including the following: 294 | 295 | - [Specify Object ID](#specify-object-id) 296 | - [Use Case 1: Annotate single field/multiple fields of associated table(s) as the Object ID](#use-case-1-annotate-single-fieldmultiple-fields-of-associated-tables-as-the-object-id) 297 | - [Use Case 2: Annotate single field/multiple fields of project customized types as the Object ID](#use-case-2-annotate-single-fieldmultiple-fields-of-project-customized-types-as-the-object-id) 298 | - [Use Case 3: Annotate chained associated entities from the current entity as the Object ID](#use-case-3-annotate-chained-associated-entities-from-the-current-entity-as-the-object-id) 299 | - [Tracing Changes](#tracing-changes) 300 | - [Use Case 1: Trace the changes of child nodes from the current entity and display the meaningful data from child nodes (composition relation)](#use-case-1-trace-the-changes-of-child-nodes-from-the-current-entity-and-display-the-meaningful-data-from-child-nodes-composition-relation) 301 | - [Use Case 2: Trace the changes of associated entities from the current entity and display the meaningful data from associated entities (association relation)](#use-case-2-trace-the-changes-of-associated-entities-from-the-current-entity-and-display-the-meaningful-data-from-associated-entities-association-relation) 302 | - [Use Case 3: Trace the changes of fields defined by project customized types and display the meaningful data](#use-case-3-trace-the-changes-of-fields-defined-by-project-customized-types-and-display-the-meaningful-data) 303 | - [Use Case 4: Trace the changes of chained associated entities from the current entity and display the meaningful data from associated entities (association relation)](#use-case-4-trace-the-changes-of-chained-associated-entities-from-the-current-entity-and-display-the-meaningful-data-from-associated-entities-association-relation) 304 | - [Use Case 5: Trace the changes of union entity and display the meaningful data](#use-case-5-trace-the-changes-of-union-entity-and-display-the-meaningful-data) 305 | - [Don'ts](#donts) 306 | - [Use Case 1: Don't trace changes for field(s) with `Association to many`](#use-case-1-dont-trace-changes-for-fields-with-association-to-many) 307 | - [Use Case 2: Don't trace changes for field(s) with *Unmanaged Association*](#use-case-2-dont-trace-changes-for-fields-with-unmanaged-association) 308 | - [Use Case 3: Don't trace changes for CUD on DB entity](#use-case-3-dont-trace-changes-for-cud-on-db-entity) 309 | 310 | ### Specify Object ID 311 | 312 | Use cases for Object ID annotation 313 | 314 | #### Use Case 1: Annotate single field/multiple fields of associated table(s) as the Object ID 315 | 316 | Modelling in `db/schema.cds` 317 | 318 | ```cds 319 | entity Incidents : cuid, managed { 320 | ... 321 | customer : Association to Customers; 322 | title : String @title: 'Title'; 323 | urgency : Association to Urgency default 'M'; 324 | status : Association to Status default 'N'; 325 | ... 326 | } 327 | ``` 328 | 329 | Add the following `@changelog` annotations in `srv/change-tracking.cds` 330 | 331 | ```cds 332 | annotate ProcessorService.Incidents with @changelog: [customer.name, urgency.code, status.criticality] { 333 | title @changelog; 334 | } 335 | ``` 336 | 337 | ![AssociationID](_assets/AssociationID.png) 338 | 339 | #### Use Case 2: Annotate single field/multiple fields of project customized types as the Object ID 340 | 341 | Modelling in `db/schema.cds` 342 | 343 | ```cds 344 | entity Incidents : cuid, managed { 345 | ... 346 | customer : Association to Customers; 347 | title : String @title: 'Title'; 348 | ... 349 | } 350 | 351 | entity Customers : cuid, managed { 352 | ... 353 | email : EMailAddress; // customized type 354 | phone : PhoneNumber; // customized type 355 | ... 356 | } 357 | ``` 358 | 359 | Add the following `@changelog` annotations in `srv/change-tracking.cds` 360 | 361 | ```cds 362 | annotate ProcessorService.Incidents with @changelog: [customer.email, customer.phone] { 363 | title @changelog; 364 | } 365 | ``` 366 | 367 | ![CustomTypeID](_assets/CustomTypeID.png) 368 | 369 | #### Use Case 3: Annotate chained associated entities from the current entity as the Object ID 370 | 371 | Modelling in `db/schema.cds` 372 | 373 | ```cds 374 | entity Incidents : cuid, managed { 375 | ... 376 | customer : Association to Customers; 377 | ... 378 | } 379 | 380 | entity Customers : cuid, managed { 381 | ... 382 | addresses : Association to Addresses; 383 | ... 384 | } 385 | ``` 386 | 387 | Add the following `@changelog` annotations in `srv/change-tracking.cds` 388 | 389 | ```cds 390 | annotate ProcessorService.Incidents with @changelog: [customer.addresses.city, customer.addresses.postCode] { 391 | title @changelog; 392 | } 393 | ``` 394 | 395 | ![ChainedAssociationID](_assets/ChainedAssociationID.png) 396 | 397 | > Change-tracking supports annotating chained associated entities from the current entity as object ID of current entity in case the entity in consumer applications is a pure relation table. However, the usage of chained associated entities is not recommended due to performance cost. 398 | 399 | ### Tracing Changes 400 | 401 | Use cases for tracing changes 402 | 403 | #### Use Case 1: Trace the changes of child nodes from the current entity and display the meaningful data from child nodes (composition relation) 404 | 405 | Modelling in `db/schema.cds` 406 | 407 | ```cds 408 | entity Incidents : managed, cuid { 409 | ... 410 | title : String @title: 'Title'; 411 | conversation : Composition of many Conversation; 412 | ... 413 | } 414 | 415 | aspect Conversation: managed, cuid { 416 | ... 417 | message : String; 418 | } 419 | ``` 420 | 421 | Add the following `@changelog` annotations in `srv/change-tracking.cds` 422 | 423 | ```cds 424 | annotate ProcessorService.Incidents with @changelog: [title] { 425 | conversation @changelog: [conversation.message]; 426 | } 427 | ``` 428 | 429 | ![CompositionChange](_assets/CompositionChange.png) 430 | 431 | #### Use Case 2: Trace the changes of associated entities from the current entity and display the meaningful data from associated entities (association relation) 432 | 433 | Modelling in `db/schema.cds` 434 | 435 | ```cds 436 | entity Incidents : cuid, managed { 437 | ... 438 | customer : Association to Customers; 439 | title : String @title: 'Title'; 440 | ... 441 | } 442 | 443 | entity Customers : cuid, managed { 444 | ... 445 | email : EMailAddress; 446 | ... 447 | } 448 | ``` 449 | 450 | Add the following `@changelog` annotations in `srv/change-tracking.cds` 451 | 452 | ```cds 453 | annotate ProcessorService.Incidents with @changelog: [title] { 454 | customer @changelog: [customer.email]; 455 | } 456 | ``` 457 | 458 | ![AssociationChange](_assets/AssociationChange.png) 459 | 460 | #### Use Case 3: Trace the changes of fields defined by project customized types and display the meaningful data 461 | 462 | Modelling in `db/schema.cds` 463 | 464 | ```cds 465 | type StatusType : Association to Status; 466 | 467 | entity Incidents : cuid, managed { 468 | ... 469 | title : String @title: 'Title'; 470 | status : StatusType default 'N'; 471 | ... 472 | } 473 | ``` 474 | 475 | Add the following `@changelog` annotations in `srv/change-tracking.cds` 476 | 477 | ```cds 478 | annotate ProcessorService.Incidents with @changelog: [title] { 479 | status @changelog: [status.code]; 480 | } 481 | ``` 482 | 483 | ![CustomTypeChange](_assets/CustomTypeChange.png) 484 | 485 | #### Use Case 4: Trace the changes of chained associated entities from the current entity and display the meaningful data from associated entities (association relation) 486 | 487 | Modelling in `db/schema.cds` 488 | 489 | ```cds 490 | entity Incidents : cuid, managed { 491 | ... 492 | title : String @title: 'Title'; 493 | customer : Association to Customers; 494 | ... 495 | } 496 | 497 | entity Customers : cuid, managed { 498 | ... 499 | addresses : Association to Addresses; 500 | ... 501 | } 502 | ``` 503 | 504 | Add the following `@changelog` annotations in `srv/change-tracking.cds` 505 | 506 | ```cds 507 | annotate ProcessorService.Incidents with @changelog: [title] { 508 | customer @changelog: [customer.addresses.city, customer.addresses.streetAddress]; 509 | } 510 | ``` 511 | 512 | ![ChainedAssociationChange](_assets/ChainedAssociationChange.png) 513 | 514 | > Change-tracking supports analyzing chained associated entities from the current entity in case the entity in consumer applications is a pure relation table. However, the usage of chained associated entities is not recommended due to performance cost. 515 | 516 | #### Use Case 5: Trace the changes of union entity and display the meaningful data 517 | 518 | `Payable.cds`: 519 | 520 | ```cds 521 | entity Payables : cuid { 522 | displayId : String; 523 | @changelog 524 | name : String; 525 | cryptoAmount : Decimal; 526 | fiatAmount : Decimal; 527 | }; 528 | ``` 529 | 530 | `Payment.cds`: 531 | 532 | ```cds 533 | entity Payments : cuid { 534 | displayId : String; //readable ID 535 | @changelog 536 | name : String; 537 | }; 538 | ``` 539 | 540 | Union entity in `BusinessTransaction.cds`: 541 | 542 | ```cds 543 | entity BusinessTransactions as( 544 | select from payments.Payments{ 545 | key ID, 546 | displayId, 547 | name, 548 | changes : Association to many ChangeView 549 | on changes.objectID = ID AND changes.entity = 'payments.Payments' 550 | } 551 | ) 552 | union all 553 | ( 554 | select from payables.Payables { 555 | key ID, 556 | displayId, 557 | name, 558 | changes : Association to many ChangeView 559 | on changes.objectID = ID AND changes.entity = 'payables.Payables' 560 | } 561 | ); 562 | ``` 563 | 564 | ![UnionChange.png](_assets/UnionChange.png) 565 | 566 | ### Don'ts 567 | 568 | Don'ts 569 | 570 | #### Use Case 1: Don't trace changes for field(s) with `Association to many` 571 | 572 | ```cds 573 | entity Customers : cuid, managed { 574 | ... 575 | incidents : Association to many Incidents on incidents.customer = $self; 576 | } 577 | ``` 578 | 579 | The reason is that: the relationship: `Association to many` is only for modelling purpose and there is no concrete field in database table. In the above sample, there is no column for incidents in the table Customers, but there is a navigation property of incidents in Customers OData entity metadata. 580 | 581 | #### Use Case 2: Don't trace changes for field(s) with *Unmanaged Association* 582 | 583 | ```cds 584 | entity AggregatedBusinessTransactionData @(cds.autoexpose) : cuid { 585 | FootprintInventory: Association to one FootprintInventories 586 | on FootprintInventory.month = month 587 | and FootprintInventory.year = year 588 | and FootprintInventory.FootprintInventoryScope.ID = FootprintInventoryScope.ID; 589 | ... 590 | } 591 | ``` 592 | 593 | The reason is that: When deploying to relational databases, Associations are mapped to foreign keys. Yet, when mapped to non-relational databases they're just references. More details could be found in [Prefer Managed Associations](https://cap.cloud.sap/docs/guides/domain-models#managed-associations). In the above sample, there is no column for FootprintInventory in the table AggregatedBusinessTransactionData, but there is a navigation property FootprintInventoryof in OData entity metadata. 594 | 595 | #### Use Case 3: Don't trace changes for CUD on DB entity 596 | 597 | ```cds 598 | this.on("UpdateActivationStatus", async (req) => 599 | // PaymentAgreementsOutgoingDb is the DB entity 600 | await UPDATE.entity(PaymentAgreementsOutgoingDb) 601 | .where({ ID: paymentAgreement.ID }) 602 | .set({ ActivationStatus_code: ActivationCodes.ACTIVE }); 603 | ); 604 | ``` 605 | 606 | The reason is that: Application level services are by design the only place where business logic is enforced. This by extension means, that it also is the only point where e.g. change-tracking would be enabled. The underlying method used to do change tracking is `req.diff` which is responsible to read the necessary before-image from the database, and this method is not available on DB level. 607 | 608 | 609 | ## Contributing 610 | 611 | This project is open to feature requests/suggestions, bug reports etc. via [GitHub issues](https://github.com/cap-js/change-tracking/issues). Contribution and feedback are encouraged and always welcome. For more information about how to contribute, the project structure, as well as additional contribution information, see our [Contribution Guidelines](CONTRIBUTING.md). 612 | 613 | ## Code of Conduct 614 | 615 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone. By participating in this project, you agree to abide by its [Code of Conduct](CODE_OF_CONDUCT.md) at all times. 616 | 617 | ## Licensing 618 | 619 | Copyright 2023 SAP SE or an SAP affiliate company and contributors. Please see our [LICENSE](LICENSE) for copyright and license information. Detailed information including third-party components and their licensing/copyright information is available [via the REUSE tool](https://api.reuse.software/info/github.com/cap-js/change-tracking). 620 | -------------------------------------------------------------------------------- /REUSE.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | SPDX-PackageName = "change-tracking" 3 | SPDX-PackageSupplier = "The CAP team " 4 | SPDX-PackageDownloadLocation = "https://github.com/cap-js/change-tracking" 5 | SPDX-PackageComment = "The code in this project may include calls to APIs (\"API Calls\") of\n SAP or third-party products or services developed outside of this project\n (\"External Products\").\n \"APIs\" means application programming interfaces, as well as their respective\n specifications and implementing code that allows software to communicate with\n other software.\n API Calls to External Products are not licensed under the open source license\n that governs this project. The use of such API Calls and related External\n Products are subject to applicable additional agreements with the relevant\n provider of the External Products. In no event shall the open source license\n that governs this project grant any rights in or to any External Products, or\n alter, expand or supersede any terms of the applicable additional agreements.\n If you have a valid license agreement with SAP for the use of a particular SAP\n External Product, then you may make use of any API Calls included in this\n project's code for that SAP External Product, subject to the terms of such\n license agreement. If you do not have a valid license agreement for the use of\n a particular SAP External Product, then you may only make use of any API Calls\n in this project for that SAP External Product for your internal, non-productive\n and non-commercial test and evaluation of such API Calls. Nothing herein grants\n you any rights to use or access any SAP External Product, or provide any third\n parties the right to use of access any SAP External Product, through API Calls." 6 | 7 | [[annotations]] 8 | path = "**" 9 | precedence = "aggregate" 10 | SPDX-FileCopyrightText = "2023 SAP SE or an SAP affiliate company and change-tracking contributors." 11 | SPDX-License-Identifier = "Apache-2.0" 12 | -------------------------------------------------------------------------------- /_assets/AssociationChange.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cap-js/change-tracking/d53779535d61ec8c05f0f2bf1973d962112ed5b4/_assets/AssociationChange.png -------------------------------------------------------------------------------- /_assets/AssociationID.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cap-js/change-tracking/d53779535d61ec8c05f0f2bf1973d962112ed5b4/_assets/AssociationID.png -------------------------------------------------------------------------------- /_assets/ChainedAssociationChange.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cap-js/change-tracking/d53779535d61ec8c05f0f2bf1973d962112ed5b4/_assets/ChainedAssociationChange.png -------------------------------------------------------------------------------- /_assets/ChainedAssociationID.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cap-js/change-tracking/d53779535d61ec8c05f0f2bf1973d962112ed5b4/_assets/ChainedAssociationID.png -------------------------------------------------------------------------------- /_assets/CompositionChange.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cap-js/change-tracking/d53779535d61ec8c05f0f2bf1973d962112ed5b4/_assets/CompositionChange.png -------------------------------------------------------------------------------- /_assets/CustomTypeChange.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cap-js/change-tracking/d53779535d61ec8c05f0f2bf1973d962112ed5b4/_assets/CustomTypeChange.png -------------------------------------------------------------------------------- /_assets/CustomTypeID.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cap-js/change-tracking/d53779535d61ec8c05f0f2bf1973d962112ed5b4/_assets/CustomTypeID.png -------------------------------------------------------------------------------- /_assets/ObjectID.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cap-js/change-tracking/d53779535d61ec8c05f0f2bf1973d962112ed5b4/_assets/ObjectID.png -------------------------------------------------------------------------------- /_assets/UnionChange.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cap-js/change-tracking/d53779535d61ec8c05f0f2bf1973d962112ed5b4/_assets/UnionChange.png -------------------------------------------------------------------------------- /_assets/changes-custom-wbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cap-js/change-tracking/d53779535d61ec8c05f0f2bf1973d962112ed5b4/_assets/changes-custom-wbox.png -------------------------------------------------------------------------------- /_assets/changes-custom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cap-js/change-tracking/d53779535d61ec8c05f0f2bf1973d962112ed5b4/_assets/changes-custom.png -------------------------------------------------------------------------------- /_assets/changes-id-hr-wbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cap-js/change-tracking/d53779535d61ec8c05f0f2bf1973d962112ed5b4/_assets/changes-id-hr-wbox.png -------------------------------------------------------------------------------- /_assets/changes-id-wbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cap-js/change-tracking/d53779535d61ec8c05f0f2bf1973d962112ed5b4/_assets/changes-id-wbox.png -------------------------------------------------------------------------------- /_assets/changes-type-hr-wbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cap-js/change-tracking/d53779535d61ec8c05f0f2bf1973d962112ed5b4/_assets/changes-type-hr-wbox.png -------------------------------------------------------------------------------- /_assets/changes-type-wbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cap-js/change-tracking/d53779535d61ec8c05f0f2bf1973d962112ed5b4/_assets/changes-type-wbox.png -------------------------------------------------------------------------------- /_assets/changes-value-hr-wbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cap-js/change-tracking/d53779535d61ec8c05f0f2bf1973d962112ed5b4/_assets/changes-value-hr-wbox.png -------------------------------------------------------------------------------- /_assets/changes-value-wbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cap-js/change-tracking/d53779535d61ec8c05f0f2bf1973d962112ed5b4/_assets/changes-value-wbox.png -------------------------------------------------------------------------------- /_assets/changes-wbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cap-js/change-tracking/d53779535d61ec8c05f0f2bf1973d962112ed5b4/_assets/changes-wbox.png -------------------------------------------------------------------------------- /_assets/changes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cap-js/change-tracking/d53779535d61ec8c05f0f2bf1973d962112ed5b4/_assets/changes.png -------------------------------------------------------------------------------- /_i18n/i18n.properties: -------------------------------------------------------------------------------- 1 | ## Change Table Fields Description## 2 | #################################### 3 | #XTIT: Field Name 4 | Changes.ID=Changes ID 5 | #XTIT: Field Name 6 | Changes.entityID=Object ID 7 | #XTIT: Field Name 8 | Changes.parentEntityID=Parent Object ID 9 | #XTIT: Field Name 10 | Changes.parentKey=Parent Key 11 | #XTIT: Field Name 12 | Changes.serviceEntityPath=Service Entity Path 13 | #XTIT: Field Name 14 | Changes.attribute=Field 15 | #XTIT: Field Name 16 | Changes.keys=Changes Keys 17 | #XTIT: Field Name 18 | Changes.modification=Change Type 19 | #XTIT: Field Name 20 | Changes.valueChangedFrom=Old Value 21 | #XTIT: Field Name 22 | Changes.valueChangedTo=New Value 23 | #XTIT: Field Name 24 | Changes.entity=Object Type 25 | #XTIT: Field Name 26 | Changes.serviceEntity=Service Object Type 27 | #XTIT: Field Name 28 | Changes.valueDataType=Value Data Type 29 | 30 | ## Change Log Table Fields Description## 31 | ######################################## 32 | #XTIT: Field Name 33 | ChangeLog.entity=Entity 34 | #XTIT: Field Name 35 | ChangeLog.entityKey=Entity Key 36 | #XTIT: Field Name 37 | ChangeLog.serviceEntity=Service Entity 38 | 39 | 40 | ## Change Log Modifications## 41 | ######################################## 42 | #XFLD: Field label 43 | ChangeLog.modification.create=Create 44 | #XFLD: Field label 45 | ChangeLog.modification.update=Update 46 | #XFLD: Field label 47 | ChangeLog.modification.delete=Delete 48 | 49 | ## Change History Table## 50 | ######################################## 51 | ChangeHistory=Change History -------------------------------------------------------------------------------- /_i18n/i18n_de.properties: -------------------------------------------------------------------------------- 1 | ## Change Table Fields Description## 2 | #################################### 3 | #XTIT: Field Name 4 | Changes.ID=ID der \u00C4nderungen 5 | #XTIT: Field Name 6 | Changes.entityID=Objekt-ID 7 | #XTIT: Field Name 8 | Changes.parentEntityID=\u00DCbergeordnete Objekt-ID 9 | #XTIT: Field Name 10 | Changes.parentKey=\u00DCbergeordneter Schl\u00FCssel 11 | #XTIT: Field Name 12 | Changes.serviceEntityPath=Pfad der Serviceentit\u00E4t 13 | #XTIT: Field Name 14 | Changes.attribute=Feld 15 | #XTIT: Field Name 16 | Changes.keys=Schl\u00FCssel der \u00C4nderungen 17 | #XTIT: Field Name 18 | Changes.modification=\u00C4nderungstyp 19 | #XTIT: Field Name 20 | Changes.valueChangedFrom=Alter Wert 21 | #XTIT: Field Name 22 | Changes.valueChangedTo=Neuer Wert 23 | #XTIT: Field Name 24 | Changes.entity=Objekttyp 25 | #XTIT: Field Name 26 | Changes.serviceEntity=Serviceobjekttyp 27 | #XTIT: Field Name 28 | Changes.valueDataType=Wertdatentyp 29 | 30 | ## Change Log Table Fields Description## 31 | ######################################## 32 | #XTIT: Field Name 33 | ChangeLog.entity=Entit\u00E4t 34 | #XTIT: Field Name 35 | ChangeLog.entityKey=Entit\u00E4tsschl\u00FCssel 36 | #XTIT: Field Name 37 | ChangeLog.serviceEntity=Serviceentit\u00E4t 38 | 39 | 40 | ## Change Log Modifications## 41 | ######################################## 42 | #XFLD: Field label 43 | ChangeLog.modification.create=Anlegen 44 | #XFLD: Field label 45 | ChangeLog.modification.update=Aktualisieren 46 | #XFLD: Field label 47 | ChangeLog.modification.delete=L\u00F6schen 48 | 49 | ## Change History Table## 50 | ######################################## 51 | ChangeHistory=Änderungshistorie -------------------------------------------------------------------------------- /_i18n/i18n_en.properties: -------------------------------------------------------------------------------- 1 | ## Change Table Fields Description## 2 | #################################### 3 | #XTIT: Field Name 4 | Changes.ID=Changes ID 5 | #XTIT: Field Name 6 | Changes.entityID=Object ID 7 | #XTIT: Field Name 8 | Changes.parentEntityID=Parent Object ID 9 | #XTIT: Field Name 10 | Changes.parentKey=Parent Key 11 | #XTIT: Field Name 12 | Changes.serviceEntityPath=Service Entity Path 13 | #XTIT: Field Name 14 | Changes.attribute=Field 15 | #XTIT: Field Name 16 | Changes.keys=Changes Keys 17 | #XTIT: Field Name 18 | Changes.modification=Change Type 19 | #XTIT: Field Name 20 | Changes.valueChangedFrom=Old Value 21 | #XTIT: Field Name 22 | Changes.valueChangedTo=New Value 23 | #XTIT: Field Name 24 | Changes.entity=Object Type 25 | #XTIT: Field Name 26 | Changes.serviceEntity=Service Object Type 27 | #XTIT: Field Name 28 | Changes.valueDataType=Value Data Type 29 | 30 | ## Change Log Table Fields Description## 31 | ######################################## 32 | #XTIT: Field Name 33 | ChangeLog.entity=Entity 34 | #XTIT: Field Name 35 | ChangeLog.entityKey=Entity Key 36 | #XTIT: Field Name 37 | ChangeLog.serviceEntity=Service Entity 38 | 39 | 40 | ## Change Log Modifications## 41 | ######################################## 42 | #XFLD: Field label 43 | ChangeLog.modification.create=Create 44 | #XFLD: Field label 45 | ChangeLog.modification.update=Update 46 | #XFLD: Field label 47 | ChangeLog.modification.delete=Delete 48 | 49 | ## Change History Table## 50 | ######################################## 51 | ChangeHistory=Change History -------------------------------------------------------------------------------- /_i18n/i18n_es.properties: -------------------------------------------------------------------------------- 1 | ## Change Table Fields Description## 2 | #################################### 3 | #XTIT: Field Name 4 | Changes.ID=ID de modificaciones 5 | #XTIT: Field Name 6 | Changes.entityID=ID de objeto 7 | #XTIT: Field Name 8 | Changes.parentEntityID=ID de objeto del nivel superior 9 | #XTIT: Field Name 10 | Changes.parentKey=Clave superior 11 | #XTIT: Field Name 12 | Changes.serviceEntityPath=V\u00EDa de acceso de entidad de servicio 13 | #XTIT: Field Name 14 | Changes.attribute=Campo 15 | #XTIT: Field Name 16 | Changes.keys=Claves de modificaciones 17 | #XTIT: Field Name 18 | Changes.modification=Tipo de modificaci\u00F3n 19 | #XTIT: Field Name 20 | Changes.valueChangedFrom=Valor antiguo 21 | #XTIT: Field Name 22 | Changes.valueChangedTo=Valor nuevo 23 | #XTIT: Field Name 24 | Changes.entity=Tipo de objeto 25 | #XTIT: Field Name 26 | Changes.serviceEntity=Tipo de objeto de servicio 27 | #XTIT: Field Name 28 | Changes.valueDataType=Tipo de dato de valor 29 | 30 | ## Change Log Table Fields Description## 31 | ######################################## 32 | #XTIT: Field Name 33 | ChangeLog.entity=Entidad 34 | #XTIT: Field Name 35 | ChangeLog.entityKey=Clave de entidad 36 | #XTIT: Field Name 37 | ChangeLog.serviceEntity=Entidad de servicio 38 | 39 | 40 | ## Change Log Modifications## 41 | ######################################## 42 | #XFLD: Field label 43 | ChangeLog.modification.create=Crear 44 | #XFLD: Field label 45 | ChangeLog.modification.update=Actualizar 46 | #XFLD: Field label 47 | ChangeLog.modification.delete=Borrar 48 | 49 | ## Change History Table## 50 | ######################################## 51 | ChangeHistory=Historial de modificaciones -------------------------------------------------------------------------------- /_i18n/i18n_fr.properties: -------------------------------------------------------------------------------- 1 | ## Change Table Fields Description## 2 | #################################### 3 | #XTIT: Field Name 4 | Changes.ID=ID de modifications 5 | #XTIT: Field Name 6 | Changes.entityID=ID d'objet 7 | #XTIT: Field Name 8 | Changes.parentEntityID=ID d'objet parent 9 | #XTIT: Field Name 10 | Changes.parentKey=Cl\u00E9 parent 11 | #XTIT: Field Name 12 | Changes.serviceEntityPath=Chemin de l'entit\u00E9 de service 13 | #XTIT: Field Name 14 | Changes.attribute=Zone 15 | #XTIT: Field Name 16 | Changes.keys=Cl\u00E9s de modifications 17 | #XTIT: Field Name 18 | Changes.modification=Type de modification 19 | #XTIT: Field Name 20 | Changes.valueChangedFrom=Ancienne valeur 21 | #XTIT: Field Name 22 | Changes.valueChangedTo=Nouvelle valeur 23 | #XTIT: Field Name 24 | Changes.entity=Type d'objet 25 | #XTIT: Field Name 26 | Changes.serviceEntity=Type d'objet de service 27 | #XTIT: Field Name 28 | Changes.valueDataType=Type de donn\u00E9es de valeur 29 | 30 | ## Change Log Table Fields Description## 31 | ######################################## 32 | #XTIT: Field Name 33 | ChangeLog.entity=Entit\u00E9 34 | #XTIT: Field Name 35 | ChangeLog.entityKey=Cl\u00E9 d'entit\u00E9 36 | #XTIT: Field Name 37 | ChangeLog.serviceEntity=Entit\u00E9 de service 38 | 39 | 40 | ## Change Log Modifications## 41 | ######################################## 42 | #XFLD: Field label 43 | ChangeLog.modification.create=Cr\u00E9er 44 | #XFLD: Field label 45 | ChangeLog.modification.update=Mettre \u00E0 jour 46 | #XFLD: Field label 47 | ChangeLog.modification.delete=Supprimer 48 | 49 | ## Change History Table## 50 | ######################################## 51 | ChangeHistory=Historique des modifications -------------------------------------------------------------------------------- /_i18n/i18n_it.properties: -------------------------------------------------------------------------------- 1 | ## Change Table Fields Description## 2 | #################################### 3 | #XTIT: Field Name 4 | Changes.ID=Cambiamenti ID 5 | #XTIT: Field Name 6 | Changes.entityID=ID oggetto 7 | #XTIT: Field Name 8 | Changes.parentEntityID=ID superiore oggetto 9 | #XTIT: Field Name 10 | Changes.parentKey=Chiave sovraordinata 11 | #XTIT: Field Name 12 | Changes.serviceEntityPath=Percorso di entità del servizio 13 | #XTIT: Field Name 14 | Changes.attribute=Campo 15 | #XTIT: Field Name 16 | Changes.keys=Cambia le chiavi 17 | #XTIT: Field Name 18 | Changes.modification=Tipo di modifica 19 | #XTIT: Field Name 20 | Changes.valueChangedFrom=Valore precedente 21 | #XTIT: Field Name 22 | Changes.valueChangedTo=Nuovo valore 23 | #XTIT: Field Name 24 | Changes.entity=Tipo di oggetto 25 | #XTIT: Field Name 26 | Changes.serviceEntity=Tipo oggetto prestazione 27 | #XTIT: Field Name 28 | Changes.valueDataType=Valutare il tipo di dati 29 | 30 | ## Change Log Table Fields Description## 31 | ######################################## 32 | #XTIT: Field Name 33 | ChangeLog.entity=Entità 34 | #XTIT: Field Name 35 | ChangeLog.entityKey=Chiave entità 36 | #XTIT: Field Name 37 | ChangeLog.serviceEntity=Entità di servizio 38 | 39 | 40 | ## Change Log Modifications## 41 | ######################################## 42 | #XFLD: Field label 43 | ChangeLog.modification.create=Creare 44 | #XFLD: Field label 45 | ChangeLog.modification.update=Aggiornare 46 | #XFLD: Field label 47 | ChangeLog.modification.delete=Cancellare 48 | 49 | ## Change History Table## 50 | ######################################## 51 | ChangeHistory=Storico modifiche -------------------------------------------------------------------------------- /_i18n/i18n_ja.properties: -------------------------------------------------------------------------------- 1 | ## Change Table Fields Description## 2 | #################################### 3 | #XTIT: Field Name 4 | Changes.ID=\u5909\u66F4 ID 5 | #XTIT: Field Name 6 | Changes.entityID=\u30AA\u30D6\u30B8\u30A7\u30AF\u30C8 ID 7 | #XTIT: Field Name 8 | Changes.parentEntityID=\u89AA\u30AA\u30D6\u30B8\u30A7\u30AF\u30C8 ID 9 | #XTIT: Field Name 10 | Changes.parentKey=\u89AA\u30AD\u30FC 11 | #XTIT: Field Name 12 | Changes.serviceEntityPath=\u30B5\u30FC\u30D3\u30B9\u30A8\u30F3\u30C6\u30A3\u30C6\u30A3\u30D1\u30B9 13 | #XTIT: Field Name 14 | Changes.attribute=\u30D5\u30A3\u30FC\u30EB\u30C9 15 | #XTIT: Field Name 16 | Changes.keys=\u5909\u66F4\u30AD\u30FC 17 | #XTIT: Field Name 18 | Changes.modification=\u5909\u66F4\u30BF\u30A4\u30D7 19 | #XTIT: Field Name 20 | Changes.valueChangedFrom=\u53E4\u3044\u5024 21 | #XTIT: Field Name 22 | Changes.valueChangedTo=\u65B0\u3057\u3044\u5024 23 | #XTIT: Field Name 24 | Changes.entity=\u30AA\u30D6\u30B8\u30A7\u30AF\u30C8\u30BF\u30A4\u30D7 25 | #XTIT: Field Name 26 | Changes.serviceEntity=\u30B5\u30FC\u30D3\u30B9\u30AA\u30D6\u30B8\u30A7\u30AF\u30C8\u30BF\u30A4\u30D7 27 | #XTIT: Field Name 28 | Changes.valueDataType=\u5024\u306E\u30C7\u30FC\u30BF\u578B 29 | 30 | ## Change Log Table Fields Description## 31 | ######################################## 32 | #XTIT: Field Name 33 | ChangeLog.entity=\u30A8\u30F3\u30C6\u30A3\u30C6\u30A3 34 | #XTIT: Field Name 35 | ChangeLog.entityKey=\u30A8\u30F3\u30C6\u30A3\u30C6\u30A3\u30AD\u30FC 36 | #XTIT: Field Name 37 | ChangeLog.serviceEntity=\u30B5\u30FC\u30D3\u30B9\u30A8\u30F3\u30C6\u30A3\u30C6\u30A3 38 | 39 | 40 | ## Change Log Modifications## 41 | ######################################## 42 | #XFLD: Field label 43 | ChangeLog.modification.create=\u4F5C\u6210 44 | #XFLD: Field label 45 | ChangeLog.modification.update=\u66F4\u65B0 46 | #XFLD: Field label 47 | ChangeLog.modification.delete=\u524A\u9664 48 | -------------------------------------------------------------------------------- /_i18n/i18n_pl.properties: -------------------------------------------------------------------------------- 1 | ## Change Table Fields Description## 2 | #################################### 3 | #XTIT: Field Name 4 | Changes.ID=Identyfikator zmian 5 | #XTIT: Field Name 6 | Changes.entityID=Identyfikator obiektu 7 | #XTIT: Field Name 8 | Changes.parentEntityID=Identyfikator obiektu nadrz\u0119dnego 9 | #XTIT: Field Name 10 | Changes.parentKey=Klucz nadrz\u0119dny 11 | #XTIT: Field Name 12 | Changes.serviceEntityPath=\u015Acie\u017Cka encji us\u0142ugi 13 | #XTIT: Field Name 14 | Changes.attribute=Pole 15 | #XTIT: Field Name 16 | Changes.keys=Klucze zmian 17 | #XTIT: Field Name 18 | Changes.modification=Typ zmiany 19 | #XTIT: Field Name 20 | Changes.valueChangedFrom=Stara warto\u015B\u0107 21 | #XTIT: Field Name 22 | Changes.valueChangedTo=Nowa warto\u015B\u0107 23 | #XTIT: Field Name 24 | Changes.entity=Typ obiektu 25 | #XTIT: Field Name 26 | Changes.serviceEntity=Typ obiektu us\u0142ugi 27 | #XTIT: Field Name 28 | Changes.valueDataType=Typ danych warto\u015Bci 29 | 30 | ## Change Log Table Fields Description## 31 | ######################################## 32 | #XTIT: Field Name 33 | ChangeLog.entity=Encja 34 | #XTIT: Field Name 35 | ChangeLog.entityKey=Klucz encji 36 | #XTIT: Field Name 37 | ChangeLog.serviceEntity=Encja us\u0142ugi 38 | 39 | 40 | ## Change Log Modifications## 41 | ######################################## 42 | #XFLD: Field label 43 | ChangeLog.modification.create=Utw\u00F3rz 44 | #XFLD: Field label 45 | ChangeLog.modification.update=Aktualizuj 46 | #XFLD: Field label 47 | ChangeLog.modification.delete=Usu\u0144 48 | 49 | ## Change History Table## 50 | ######################################## 51 | ChangeHistory=Historia zmian -------------------------------------------------------------------------------- /_i18n/i18n_pt.properties: -------------------------------------------------------------------------------- 1 | ## Change Table Fields Description## 2 | #################################### 3 | #XTIT: Field Name 4 | Changes.ID=ID das altera\u00E7\u00F5es 5 | #XTIT: Field Name 6 | Changes.entityID=ID de objeto 7 | #XTIT: Field Name 8 | Changes.parentEntityID=ID do objeto superior 9 | #XTIT: Field Name 10 | Changes.parentKey=Chave superior 11 | #XTIT: Field Name 12 | Changes.serviceEntityPath=Caminho da entidade de servi\u00E7o 13 | #XTIT: Field Name 14 | Changes.attribute=Campo 15 | #XTIT: Field Name 16 | Changes.keys=Chaves de altera\u00E7\u00F5es 17 | #XTIT: Field Name 18 | Changes.modification=Tipo de altera\u00E7\u00E3o 19 | #XTIT: Field Name 20 | Changes.valueChangedFrom=Valor antigo 21 | #XTIT: Field Name 22 | Changes.valueChangedTo=Valor novo 23 | #XTIT: Field Name 24 | Changes.entity=Tipo de objeto 25 | #XTIT: Field Name 26 | Changes.serviceEntity=Tipo de objeto de servi\u00E7o 27 | #XTIT: Field Name 28 | Changes.valueDataType=Tipo de dados de valor 29 | 30 | ## Change Log Table Fields Description## 31 | ######################################## 32 | #XTIT: Field Name 33 | ChangeLog.entity=Entidade 34 | #XTIT: Field Name 35 | ChangeLog.entityKey=Chave de entidade 36 | #XTIT: Field Name 37 | ChangeLog.serviceEntity=Entidade de servi\u00E7o 38 | 39 | 40 | ## Change Log Modifications## 41 | ######################################## 42 | #XFLD: Field label 43 | ChangeLog.modification.create=Criar 44 | #XFLD: Field label 45 | ChangeLog.modification.update=Atualizar 46 | #XFLD: Field label 47 | ChangeLog.modification.delete=Eliminar 48 | 49 | ## Change History Table## 50 | ######################################## 51 | ChangeHistory=Histórico de alterações -------------------------------------------------------------------------------- /_i18n/i18n_ru.properties: -------------------------------------------------------------------------------- 1 | ## Change Table Fields Description## 2 | #################################### 3 | #XTIT: Field Name 4 | Changes.ID=\u0418\u0434. \u0438\u0437\u043C\u0435\u043D\u0435\u043D\u0438\u044F 5 | #XTIT: Field Name 6 | Changes.entityID=\u0418\u0434. \u043E\u0431\u044A\u0435\u043A\u0442\u0430 7 | #XTIT: Field Name 8 | Changes.parentEntityID=\u0418\u0434. \u0432\u044B\u0448\u0435\u0441\u0442\u043E\u044F\u0449\u0435\u0433\u043E \u043E\u0431\u044A\u0435\u043A\u0442\u0430 9 | #XTIT: Field Name 10 | Changes.parentKey=\u0412\u044B\u0448\u0435\u0441\u0442\u043E\u044F\u0449\u0438\u0439 \u043A\u043B\u044E\u0447 11 | #XTIT: Field Name 12 | Changes.serviceEntityPath=\u041F\u0443\u0442\u044C \u043A \u0441\u0443\u0449\u043D\u043E\u0441\u0442\u0438 \u0441\u0435\u0440\u0432\u0438\u0441\u0430 13 | #XTIT: Field Name 14 | Changes.attribute=\u041F\u043E\u043B\u0435 15 | #XTIT: Field Name 16 | Changes.keys=\u041A\u043B\u044E\u0447\u0438 \u0438\u0437\u043C\u0435\u043D\u0435\u043D\u0438\u0439 17 | #XTIT: Field Name 18 | Changes.modification=\u0422\u0438\u043F \u0438\u0437\u043C\u0435\u043D\u0435\u043D\u0438\u044F 19 | #XTIT: Field Name 20 | Changes.valueChangedFrom=\u0421\u0442\u0430\u0440\u043E\u0435 \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435 21 | #XTIT: Field Name 22 | Changes.valueChangedTo=\u041D\u043E\u0432\u043E\u0435 \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435 23 | #XTIT: Field Name 24 | Changes.entity=\u0422\u0438\u043F \u043E\u0431\u044A\u0435\u043A\u0442\u0430 25 | #XTIT: Field Name 26 | Changes.serviceEntity=\u0422\u0438\u043F \u043E\u0431\u044A\u0435\u043A\u0442\u0430 \u0441\u0435\u0440\u0432\u0438\u0441\u0430 27 | #XTIT: Field Name 28 | Changes.valueDataType=\u0422\u0438\u043F \u0434\u0430\u043D\u043D\u044B\u0445 \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u044F 29 | 30 | ## Change Log Table Fields Description## 31 | ######################################## 32 | #XTIT: Field Name 33 | ChangeLog.entity=\u0421\u0443\u0449\u043D\u043E\u0441\u0442\u044C 34 | #XTIT: Field Name 35 | ChangeLog.entityKey=\u041A\u043B\u044E\u0447 \u0441\u0443\u0449\u043D\u043E\u0441\u0442\u0438 36 | #XTIT: Field Name 37 | ChangeLog.serviceEntity=\u0421\u0443\u0449\u043D\u043E\u0441\u0442\u044C \u0441\u0435\u0440\u0432\u0438\u0441\u0430 38 | 39 | 40 | ## Change Log Modifications## 41 | ######################################## 42 | #XFLD: Field label 43 | ChangeLog.modification.create=\u0421\u043E\u0437\u0434\u0430\u0442\u044C 44 | #XFLD: Field label 45 | ChangeLog.modification.update=\u041E\u0431\u043D\u043E\u0432\u0438\u0442\u044C 46 | #XFLD: Field label 47 | ChangeLog.modification.delete=\u0423\u0434\u0430\u043B\u0438\u0442\u044C 48 | 49 | ## Change History Table## 50 | ######################################## 51 | ChangeHistory=История изменений -------------------------------------------------------------------------------- /_i18n/i18n_zh_CN.properties: -------------------------------------------------------------------------------- 1 | ## Change Table Fields Description## 2 | #################################### 3 | #XTIT: Field Name 4 | Changes.ID=\u66F4\u6539\u6807\u8BC6 5 | #XTIT: Field Name 6 | Changes.entityID=\u5BF9\u8C61\u6807\u8BC6 7 | #XTIT: Field Name 8 | Changes.parentEntityID=\u7236\u5BF9\u8C61\u6807\u8BC6 9 | #XTIT: Field Name 10 | Changes.parentKey=\u7236\u53C2\u6570 11 | #XTIT: Field Name 12 | Changes.serviceEntityPath=\u670D\u52A1\u5B9E\u4F53\u8DEF\u5F84 13 | #XTIT: Field Name 14 | Changes.attribute=\u5B57\u6BB5 15 | #XTIT: Field Name 16 | Changes.keys=\u66F4\u6539\u53C2\u6570 17 | #XTIT: Field Name 18 | Changes.modification=\u66F4\u6539\u7C7B\u578B 19 | #XTIT: Field Name 20 | Changes.valueChangedFrom=\u65E7\u503C 21 | #XTIT: Field Name 22 | Changes.valueChangedTo=\u65B0\u503C 23 | #XTIT: Field Name 24 | Changes.entity=\u5BF9\u8C61\u7C7B\u578B 25 | #XTIT: Field Name 26 | Changes.serviceEntity=\u670D\u52A1\u5BF9\u8C61\u7C7B\u578B 27 | #XTIT: Field Name 28 | Changes.valueDataType=\u6570\u503C\u6570\u636E\u7C7B\u578B 29 | 30 | ## Change Log Table Fields Description## 31 | ######################################## 32 | #XTIT: Field Name 33 | ChangeLog.entity=\u5B9E\u4F53 34 | #XTIT: Field Name 35 | ChangeLog.entityKey=\u5B9E\u4F53\u53C2\u6570 36 | #XTIT: Field Name 37 | ChangeLog.serviceEntity=\u670D\u52A1\u5B9E\u4F53 38 | 39 | 40 | ## Change Log Modifications## 41 | ######################################## 42 | #XFLD: Field label 43 | ChangeLog.modification.create=\u521B\u5EFA 44 | #XFLD: Field label 45 | ChangeLog.modification.update=\u66F4\u65B0 46 | #XFLD: Field label 47 | ChangeLog.modification.delete=\u5220\u9664 48 | 49 | ## Change History Table## 50 | ######################################## 51 | ChangeHistory=\u6539\u53d8\u5386\u53f2 -------------------------------------------------------------------------------- /cds-plugin.js: -------------------------------------------------------------------------------- 1 | const cds = require('@sap/cds') 2 | const DEBUG = cds.debug('changelog') 3 | 4 | const isRoot = 'change-tracking-isRootEntity' 5 | const hasParent = 'change-tracking-parentEntity' 6 | 7 | const isChangeTracked = (entity) => { 8 | if (entity.query?.SET?.op === 'union') return false // REVISIT: should that be an error or warning? 9 | if (entity['@changelog']) return true 10 | if (Object.values(entity.elements).some(e => e['@changelog'])) return true 11 | } 12 | 13 | // Add the appropriate Side Effects attribute to the custom action 14 | const addSideEffects = (actions, flag, element) => { 15 | if (!flag && (element === undefined || element === null)) { 16 | return 17 | } 18 | 19 | for (const se of Object.values(actions)) { 20 | const target = flag ? 'TargetProperties' : 'TargetEntities' 21 | const sideEffectAttr = se[`@Common.SideEffects.${target}`] 22 | const property = flag ? 'changes' : { '=': `${element}.changes` } 23 | if (sideEffectAttr?.length >= 0) { 24 | sideEffectAttr.findIndex( 25 | (item) => 26 | (item['='] ? item['='] : item) === 27 | (property['='] ? property['='] : property) 28 | ) === -1 && sideEffectAttr.push(property) 29 | } else { 30 | se[`@Common.SideEffects.${target}`] = [property] 31 | } 32 | } 33 | } 34 | 35 | function setChangeTrackingIsRootEntity (entity, csn, val = true) { 36 | if (csn.definitions?.[entity.name]) { 37 | csn.definitions[entity.name][isRoot] = val 38 | } 39 | } 40 | 41 | function checkAndSetRootEntity (parentEntity, entity, csn) { 42 | if (entity[isRoot] === false) { 43 | return entity 44 | } 45 | if (parentEntity) { 46 | return compositionRoot(parentEntity, csn) 47 | } else { 48 | setChangeTrackingIsRootEntity(entity, csn) 49 | return { ...csn.definitions?.[entity.name], name: entity.name } 50 | } 51 | } 52 | 53 | function processEntities (m) { 54 | for (let name in m.definitions) { 55 | compositionRoot({ ...m.definitions[name], name }, m) 56 | } 57 | } 58 | 59 | function compositionRoot (entity, csn) { 60 | if (!entity || entity.kind !== 'entity') { 61 | return 62 | } 63 | const parentEntity = compositionParent(entity, csn) 64 | return checkAndSetRootEntity(parentEntity, entity, csn) 65 | } 66 | 67 | function compositionParent (entity, csn) { 68 | if (!entity || entity.kind !== 'entity') { 69 | return 70 | } 71 | const parentAssociation = compositionParentAssociation(entity, csn) 72 | return parentAssociation ?? null 73 | } 74 | 75 | function compositionParentAssociation (entity, csn) { 76 | if (!entity || entity.kind !== 'entity') { 77 | return 78 | } 79 | const elements = entity.elements ?? {} 80 | 81 | // Add the change-tracking-isRootEntity attribute of the child entity 82 | processCompositionElements(entity, csn, elements) 83 | 84 | const hasChildFlag = entity[isRoot] !== false 85 | const hasParentEntity = entity[hasParent] 86 | 87 | if (hasChildFlag || !hasParentEntity) { 88 | // Find parent association of the entity 89 | const parentAssociation = findParentAssociation(entity, csn, elements) 90 | if (parentAssociation) { 91 | const parentAssociationTarget = elements[parentAssociation]?.target 92 | if (hasChildFlag) setChangeTrackingIsRootEntity(entity, csn, false) 93 | return { 94 | ...csn.definitions?.[parentAssociationTarget], 95 | name: parentAssociationTarget 96 | } 97 | } else return 98 | } 99 | return { ...csn.definitions?.[entity.name], name: entity.name } 100 | } 101 | 102 | function processCompositionElements (entity, csn, elements) { 103 | for (const name in elements) { 104 | const element = elements[name] 105 | const target = element?.target 106 | const definition = csn.definitions?.[target] 107 | if ( 108 | element.type !== 'cds.Composition' || 109 | target === entity.name || 110 | !definition || 111 | definition[isRoot] === false 112 | ) { 113 | continue 114 | } 115 | setChangeTrackingIsRootEntity({ ...definition, name: target }, csn, false) 116 | } 117 | } 118 | 119 | function findParentAssociation (entity, csn, elements) { 120 | return Object.keys(elements).find((name) => { 121 | const element = elements[name] 122 | const target = element?.target 123 | if (element.type === 'cds.Association' && target !== entity.name) { 124 | const parentDefinition = csn.definitions?.[target] ?? {} 125 | const parentElements = parentDefinition?.elements ?? {} 126 | return !!Object.keys(parentElements).find((parentEntityName) => { 127 | const parentElement = parentElements?.[parentEntityName] ?? {} 128 | if (parentElement.type === 'cds.Composition') { 129 | const isCompositionEntity = parentElement.target === entity.name 130 | // add parent information in the current entity 131 | if (isCompositionEntity) { 132 | csn.definitions[entity.name][hasParent] = { 133 | associationName: name, 134 | entityName: target 135 | } 136 | } 137 | return isCompositionEntity 138 | } 139 | }) 140 | } 141 | }) 142 | } 143 | 144 | 145 | 146 | /** 147 | * Returns an expression for the key of the given entity, which we can use as the right-hand-side of an ON condition. 148 | */ 149 | function entityKey4 (entity) { 150 | const xpr = [] 151 | for (let k in entity.elements) { 152 | const e = entity.elements[k]; if (!e.key) continue 153 | if (xpr.length) xpr.push('||') 154 | if (e.type === 'cds.Association') xpr.push({ ref: [k, e.keys?.[0]?.ref?.[0]] }) 155 | else xpr.push({ ref:[k] }) 156 | } 157 | return xpr 158 | } 159 | 160 | 161 | // Unfold @changelog annotations in loaded model 162 | function enhanceModel (m) { 163 | 164 | const _enhanced = 'sap.changelog.enhanced' 165 | if (m.meta?.[_enhanced]) return // already enhanced 166 | 167 | // Get definitions from Dummy entity in our models 168 | const { 'sap.changelog.aspect': aspect } = m.definitions; if (!aspect) return // some other model 169 | const { '@UI.Facets': [facet], elements: { changes } } = aspect 170 | if (changes.on.length > 2) changes.on.pop() // remove ID -> filled in below 171 | 172 | processEntities(m) // REVISIT: why is that required ?!? 173 | 174 | for (let name in m.definitions) { 175 | 176 | const entity = m.definitions[name] 177 | if (entity.kind === 'entity' && !entity['@cds.autoexposed'] && isChangeTracked(entity)) { 178 | 179 | if (!entity['@changelog.disable_assoc']) { 180 | 181 | // Add association to ChangeView... 182 | const keys = entityKey4(entity); if (!keys.length) continue // If no key attribute is defined for the entity, the logic to add association to ChangeView should be skipped. 183 | const assoc = { ...changes, on: [ ...changes.on, ...keys ] } 184 | 185 | // -------------------------------------------------------------------- 186 | // PARKED: Add auto-exposed projection on ChangeView to service if applicable 187 | // const namespace = name.match(/^(.*)\.[^.]+$/)[1] 188 | // const service = m.definitions[namespace] 189 | // if (service) { 190 | // const projection = {from:{ref:[assoc.target]}} 191 | // m.definitions[assoc.target = namespace + '.' + Changes] = { 192 | // '@cds.autoexposed':true, kind:'entity', projection 193 | // } 194 | // DEBUG?.(`\n 195 | // extend service ${namespace} with { 196 | // entity ${Changes} as projection on ${projection.from.ref[0]}; 197 | // } 198 | // `.replace(/ {10}/g,'')) 199 | // } 200 | // -------------------------------------------------------------------- 201 | 202 | DEBUG?.(`\n 203 | extend ${name} with { 204 | changes : Association to many ${assoc.target} on ${ assoc.on.map(x => x.ref?.join('.') || x).join(' ') }; 205 | } 206 | `.replace(/ {8}/g,'')) 207 | const query = entity.projection || entity.query?.SELECT 208 | if (query) (query.columns ??= ['*']).push({ as: 'changes', cast: assoc }) 209 | else if (entity.elements) entity.elements.changes = assoc 210 | 211 | // Add UI.Facet for Change History List 212 | if (!entity['@changelog.disable_facet']) 213 | entity['@UI.Facets']?.push(facet) 214 | } 215 | 216 | if (entity.actions) { 217 | const hasParentInfo = entity[hasParent] 218 | const entityName = hasParentInfo?.entityName 219 | const parentEntity = entityName ? m.definitions[entityName] : null 220 | const isParentRootAndHasFacets = parentEntity?.[isRoot] && parentEntity?.['@UI.Facets'] 221 | if (entity[isRoot] && entity['@UI.Facets']) { 222 | // Add side effects for root entity 223 | addSideEffects(entity.actions, true) 224 | } else if (isParentRootAndHasFacets) { 225 | // Add side effects for child entity 226 | addSideEffects(entity.actions, false, hasParentInfo?.associationName) 227 | } 228 | } 229 | } 230 | } 231 | (m.meta ??= {})[_enhanced] = true 232 | } 233 | 234 | // Add generic change tracking handlers 235 | function addGenericHandlers() { 236 | const { track_changes, _afterReadChangeView } = require("./lib/change-log") 237 | for (const srv of cds.services) { 238 | if (srv instanceof cds.ApplicationService) { 239 | let any = false 240 | for (const entity of Object.values(srv.entities)) { 241 | if (isChangeTracked(entity)) { 242 | cds.db.before("CREATE", entity, track_changes) 243 | cds.db.before("UPDATE", entity, track_changes) 244 | cds.db.before("DELETE", entity, track_changes) 245 | any = true 246 | } 247 | } 248 | if (any && srv.entities.ChangeView) { 249 | srv.after("READ", srv.entities.ChangeView, _afterReadChangeView) 250 | } 251 | } 252 | } 253 | } 254 | 255 | 256 | // Register plugin hooks 257 | cds.on('compile.for.runtime', csn => { DEBUG?.('on','compile.for.runtime'); enhanceModel(csn) }) 258 | cds.on('compile.to.edmx', csn => { DEBUG?.('on','compile.to.edmx'); enhanceModel(csn) }) 259 | cds.on('compile.to.dbx', csn => { DEBUG?.('on','compile.to.dbx'); enhanceModel(csn) }) 260 | cds.on('served', addGenericHandlers) 261 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import cds from '@sap/cds/eslint.config.mjs' 2 | export default [ ...cds ] 3 | -------------------------------------------------------------------------------- /index.cds: -------------------------------------------------------------------------------- 1 | using { managed, cuid } from '@sap/cds/common'; 2 | namespace sap.changelog; 3 | 4 | /** 5 | * Used in cds-plugin.js as template for tracked entities 6 | */ 7 | @cds.persistence.skip entity aspect @(UI.Facets: [{ 8 | $Type : 'UI.ReferenceFacet', 9 | ID : 'ChangeHistoryFacet', 10 | Label : '{i18n>ChangeHistory}', 11 | Target: 'changes/@UI.PresentationVariant', 12 | ![@UI.PartOfPreview]: false 13 | }]) { 14 | // Essentially: Association to many Changes on changes.changeLog.entityKey = ID; 15 | changes : Association to many ChangeView on changes.entityKey = ID; 16 | key ID : UUID; 17 | } 18 | 19 | 20 | // This is a helper view to flatten the assoc path to the entityKey 21 | @readonly 22 | view ChangeView as 23 | select from Changes { 24 | *, 25 | entityID as objectID, // no clue why we have to rename this? 26 | parentEntityID as parentObjectID, // no clue why we have to rename this? 27 | changeLog.entityKey as entityKey, // flattening assoc path -> this is the main reason for having this helper view 28 | changeLog.createdAt as createdAt, 29 | changeLog.createdBy as createdBy, 30 | } 31 | excluding { 32 | entityID, 33 | parentEntityID, 34 | }; 35 | 36 | /** 37 | * Top-level changes entity, e.g. UPDATE Incident by, at, ... 38 | */ 39 | entity ChangeLog : managed, cuid { 40 | serviceEntity : String(5000) @title: '{i18n>ChangeLog.serviceEntity}'; // definition name of target entity (on service level) - e.g. ProcessorsService.Incidents 41 | entity : String(5000) @title: '{i18n>ChangeLog.entity}'; // definition name of target entity (on db level) - e.g. sap.capire.incidents.Incidents 42 | entityKey : UUID @title: '{i18n>ChangeLog.entityKey}'; // primary key of target entity, e.g. Incidents.ID 43 | createdAt : managed:createdAt; 44 | createdBy : managed:createdBy; 45 | changes : Composition of many Changes on changes.changeLog = $self; 46 | } 47 | 48 | /** 49 | * Attribute-level Changes with simple capturing of one-level 50 | * composition trees in parent... elements. 51 | */ 52 | entity Changes { 53 | 54 | key ID : UUID @UI.Hidden; 55 | keys : String(5000) @title: '{i18n>Changes.keys}'; 56 | attribute : String(5000) @title: '{i18n>Changes.attribute}'; 57 | valueChangedFrom : String(5000) @title: '{i18n>Changes.valueChangedFrom}' @UI.MultiLineText; 58 | valueChangedTo : String(5000) @title: '{i18n>Changes.valueChangedTo}' @UI.MultiLineText; 59 | 60 | // Business meaningful object id 61 | entityID : String(5000) @title: '{i18n>Changes.entityID}'; 62 | entity : String(5000) @title: '{i18n>Changes.entity}'; // similar to ChangeLog.entity, but could be nested entity in a composition tree 63 | serviceEntity : String(5000) @title: '{i18n>Changes.serviceEntity}'; // similar to ChangeLog.serviceEntity, but could be nested entity in a composition tree 64 | 65 | // Business meaningful parent object id 66 | parentEntityID : String(5000) @title: '{i18n>Changes.parentEntityID}'; 67 | parentKey : UUID @title: '{i18n>Changes.parentKey}'; 68 | serviceEntityPath : String(5000) @title: '{i18n>Changes.serviceEntityPath}'; 69 | 70 | @title: '{i18n>Changes.modification}' 71 | modification : String enum { 72 | Create = 'create'; 73 | Update = 'update'; 74 | Delete = 'delete'; 75 | }; 76 | 77 | valueDataType : String(5000) @title: '{i18n>Changes.valueDataType}'; 78 | changeLog : Association to ChangeLog @title: '{i18n>ChangeLog.ID}' @UI.Hidden; 79 | } 80 | 81 | annotate ChangeView with @(UI: { 82 | PresentationVariant: { 83 | Visualizations: ['@UI.LineItem'], 84 | RequestAtLeast: [ 85 | parentKey, 86 | serviceEntity, 87 | serviceEntityPath 88 | ], 89 | SortOrder : [{ 90 | Property : createdAt, 91 | Descending: true 92 | }], 93 | }, 94 | LineItem : [ 95 | { Value: modification, @HTML5.CssDefaults: {width:'9%'} }, 96 | { Value: createdAt, @HTML5.CssDefaults: {width:'12%'} }, 97 | { Value: createdBy, @HTML5.CssDefaults: {width:'9%'} }, 98 | { Value: entity, @HTML5.CssDefaults: {width:'11%'} }, 99 | { Value: objectID, @HTML5.CssDefaults: {width:'14%'} }, 100 | { Value: attribute, @HTML5.CssDefaults: {width:'9%'} }, 101 | { Value: valueChangedTo, @HTML5.CssDefaults: {width:'11%'} }, 102 | { Value: valueChangedFrom, @HTML5.CssDefaults: {width:'11%'} }, 103 | { Value: parentObjectID, @HTML5.CssDefaults: {width:'14%'}, ![@UI.Hidden]: true } 104 | ], 105 | DeleteHidden : true, 106 | }); 107 | -------------------------------------------------------------------------------- /lib/change-log.js: -------------------------------------------------------------------------------- 1 | const cds = require("@sap/cds") 2 | const getTemplate = require("@sap/cds/libx/_runtime/common/utils/template") // REVISIT: bad usage of internal stuff 3 | const templateProcessor = require("./template-processor") 4 | const LOG = cds.log("change-log") 5 | 6 | const { 7 | getNameFromPathVal, 8 | getUUIDFromPathVal, 9 | getCurObjFromReqData, 10 | getCurObjFromDbQuery, 11 | getObjectId, 12 | getDBEntity, 13 | getEntityByContextPath, 14 | getObjIdElementNamesInArray, 15 | getValueEntityType, 16 | } = require("./entity-helper") 17 | const { localizeLogFields } = require("./localization") 18 | const isRoot = "change-tracking-isRootEntity" 19 | 20 | 21 | function formatDecimal(str, scale) { 22 | if (typeof str === "number" && !isNaN(str)) { 23 | str = String(str); 24 | } else return str; 25 | 26 | if (scale > 0) { 27 | let parts = str.split("."); 28 | let decimalPart = parts[1] || ""; 29 | 30 | while (decimalPart.length < scale) { 31 | decimalPart += "0"; 32 | } 33 | 34 | return `${parts[0]}.${decimalPart}`; 35 | } 36 | 37 | return str; 38 | } 39 | 40 | const _getRootEntityPathVals = function (txContext, entity, entityKey) { 41 | const serviceEntityPathVals = [] 42 | const entityIDs = _getEntityIDs(txContext.params) 43 | 44 | let path = txContext.path.split('/') 45 | 46 | if (txContext.event === "CREATE") { 47 | const curEntityPathVal = `${entity.name}(${entityKey})` 48 | serviceEntityPathVals.push(curEntityPathVal) 49 | txContext.hasComp && entityIDs.pop(); 50 | } else { 51 | // When deleting Composition of one node via REST API in draft-disabled mode, 52 | // the child node ID would be missing in URI 53 | if (txContext.event === "DELETE" && !entityIDs.find((x) => x === entityKey)) { 54 | entityIDs.push(entityKey) 55 | } 56 | const curEntity = getEntityByContextPath(path, txContext.hasComp) 57 | const curEntityID = entityIDs.pop() 58 | const curEntityPathVal = `${curEntity.name}(${curEntityID})` 59 | serviceEntityPathVals.push(curEntityPathVal) 60 | } 61 | 62 | 63 | while (_isCompositionContextPath(path, txContext.hasComp)) { 64 | const hostEntity = getEntityByContextPath(path = path.slice(0, -1), txContext.hasComp) 65 | const hostEntityID = entityIDs.pop() 66 | const hostEntityPathVal = `${hostEntity.name}(${hostEntityID})` 67 | serviceEntityPathVals.unshift(hostEntityPathVal) 68 | } 69 | 70 | return serviceEntityPathVals 71 | } 72 | 73 | const _getAllPathVals = function (txContext) { 74 | const pathVals = [] 75 | const paths = txContext.path.split('/') 76 | const entityIDs = _getEntityIDs(txContext.params) 77 | 78 | for (let idx = 0; idx < paths.length; idx++) { 79 | const entity = getEntityByContextPath(paths.slice(0, idx + 1), txContext.hasComp) 80 | const entityID = entityIDs[idx] 81 | const entityPathVal = `${entity.name}(${entityID})` 82 | pathVals.push(entityPathVal) 83 | } 84 | 85 | return pathVals 86 | } 87 | 88 | function convertSubjectToParams(subject) { 89 | let params = []; 90 | let subjectRef = []; 91 | subject?.ref?.forEach((item)=>{ 92 | if (typeof item === 'string') { 93 | subjectRef.push(item) 94 | return 95 | } 96 | 97 | const keys = {} 98 | let id = item.id 99 | if (!id) return 100 | for (let j = 0; j < item?.where?.length; j = j + 4) { 101 | const key = item.where[j].ref[0] 102 | const value = item.where[j + 2].val 103 | if (key !== 'IsActiveEntity') keys[key] = value 104 | } 105 | params.push(keys); 106 | }) 107 | return params.length > 0 ? params : subjectRef; 108 | } 109 | 110 | const _getEntityIDs = function (txParams) { 111 | const entityIDs = [] 112 | for (const param of txParams) { 113 | let id = "" 114 | if (typeof param === "object" && !Array.isArray(param)) { 115 | id = param.ID 116 | } 117 | if (typeof param === "string") { 118 | id = param 119 | } 120 | if (id) { 121 | entityIDs.push(id) 122 | } 123 | } 124 | return entityIDs 125 | } 126 | 127 | /** 128 | * 129 | * @param {*} tx 130 | * @param {*} changes 131 | * 132 | * When consuming app implement '@changelog' on an association element, 133 | * change history will use attribute on associated entity which are specified instead of default technical foreign key. 134 | * 135 | * eg: 136 | * entity PurchasedProductFootprints @(cds.autoexpose): cuid, managed { 137 | * ... 138 | * '@changelog': [Plant.identifier] 139 | * '@mandatory' Plant : Association to one Plant; 140 | * ... 141 | * } 142 | */ 143 | const _formatAssociationContext = async function (changes, reqData) { 144 | for (const change of changes) { 145 | const a = cds.model.definitions[change.serviceEntity].elements[change.attribute] 146 | if (a?.type !== "cds.Association") continue 147 | 148 | const semkeys = getObjIdElementNamesInArray(a["@changelog"]) 149 | if (!semkeys.length) continue 150 | 151 | const ID = a.keys[0].ref[0] || 'ID' 152 | const [ from, to ] = await cds.db.run ([ 153 | SELECT.one.from(a.target).where({ [ID]: change.valueChangedFrom }), 154 | SELECT.one.from(a.target).where({ [ID]: change.valueChangedTo }) 155 | ]) 156 | 157 | const fromObjId = await getObjectId(reqData, a.target, semkeys, { curObjFromDbQuery: from || undefined }) // Note: ... || undefined is important for subsequent object destructuring with defaults 158 | if (fromObjId) change.valueChangedFrom = fromObjId 159 | 160 | const toObjId = await getObjectId(reqData, a.target, semkeys, { curObjFromDbQuery: to || undefined }) // Note: ... || undefined is important for subsequent object destructuring with defaults 161 | if (toObjId) change.valueChangedTo = toObjId 162 | 163 | const isVLvA = a["@Common.ValueList.viaAssociation"] 164 | if (!isVLvA) change.valueDataType = getValueEntityType(a.target, semkeys) 165 | } 166 | } 167 | 168 | const _getChildChangeObjId = async function ( 169 | change, 170 | childNodeChange, 171 | curNodePathVal, 172 | reqData 173 | ) { 174 | const composition = cds.model.definitions[change.serviceEntity].elements[change.attribute] 175 | const objIdElements = composition ? composition["@changelog"] : null 176 | const objIdElementNames = getObjIdElementNamesInArray(objIdElements) 177 | 178 | return _getObjectIdByPath( 179 | reqData, 180 | curNodePathVal, 181 | childNodeChange._path, 182 | objIdElementNames 183 | ) 184 | } 185 | 186 | const _formatCompositionContext = async function (changes, reqData) { 187 | const childNodeChanges = [] 188 | 189 | for (const change of changes) { 190 | if (typeof change.valueChangedTo === "object" && change.valueChangedTo instanceof Date !== true) { 191 | if (!Array.isArray(change.valueChangedTo)) { 192 | change.valueChangedTo = [change.valueChangedTo] 193 | } 194 | for (const childNodeChange of change.valueChangedTo) { 195 | const curChange = Object.assign({}, change) 196 | const path = childNodeChange._path.split('/') 197 | const curNodePathVal = path.pop() 198 | curChange.modification = childNodeChange._op 199 | const objId = await _getChildChangeObjId( 200 | change, 201 | childNodeChange, 202 | curNodePathVal, 203 | reqData 204 | ) 205 | _formatCompositionValue(curChange, objId, childNodeChange, childNodeChanges) 206 | } 207 | change.valueChangedTo = undefined 208 | } 209 | } 210 | changes.push(...childNodeChanges) 211 | } 212 | 213 | const _formatCompositionValue = function ( 214 | curChange, 215 | objId, 216 | childNodeChange, 217 | childNodeChanges 218 | ) { 219 | if (curChange.modification === undefined) { 220 | return 221 | } else if (curChange.modification === "delete") { 222 | curChange.valueChangedFrom = objId 223 | curChange.valueChangedTo = "" 224 | } else if (curChange.modification === "update") { 225 | curChange.valueChangedFrom = objId 226 | curChange.valueChangedTo = objId 227 | } else { 228 | curChange.valueChangedFrom = "" 229 | curChange.valueChangedTo = objId 230 | } 231 | curChange.valueDataType = _formatCompositionEntityType(curChange) 232 | // Since req.diff() will record the managed data, change history will filter those logs only be changed managed data 233 | const managedAttrs = ["modifiedAt", "modifiedBy"] 234 | if (curChange.modification === "update") { 235 | const rowOldAttrs = Object.keys(childNodeChange._old) 236 | const diffAttrs = rowOldAttrs.filter((attr) => managedAttrs.indexOf(attr) === -1) 237 | if (!diffAttrs.length) { 238 | return 239 | } 240 | } 241 | childNodeChanges.push(curChange) 242 | } 243 | 244 | const _formatCompositionEntityType = function (change) { 245 | const composition = cds.model.definitions[change.serviceEntity].elements[change.attribute] 246 | const objIdElements = composition ? composition['@changelog'] : null 247 | 248 | if (Array.isArray(objIdElements)) { 249 | // In this case, the attribute is a composition 250 | const objIdElementNames = getObjIdElementNamesInArray(objIdElements) 251 | return getValueEntityType(composition.target, objIdElementNames) 252 | } 253 | return "" 254 | } 255 | 256 | const _getObjectIdByPath = async function ( 257 | reqData, 258 | nodePathVal, 259 | serviceEntityPath, 260 | /**optional*/ objIdElementNames 261 | ) { 262 | const curObjFromReqData = getCurObjFromReqData(reqData, nodePathVal, serviceEntityPath) 263 | const entityName = getNameFromPathVal(nodePathVal) 264 | const entityUUID = getUUIDFromPathVal(nodePathVal) 265 | const obj = await getCurObjFromDbQuery(entityName, entityUUID) 266 | const curObj = { curObjFromReqData, curObjFromDbQuery: obj } 267 | return getObjectId(reqData, entityName, objIdElementNames, curObj) 268 | } 269 | 270 | const _formatObjectID = async function (changes, reqData) { 271 | const objectIdCache = new Map() 272 | for (const change of changes) { 273 | const path = change.serviceEntityPath.split('/') 274 | const curNodePathVal = path.pop() 275 | const parentNodePathVal = path.pop() 276 | 277 | let curNodeObjId = objectIdCache.get(curNodePathVal) 278 | if (!curNodeObjId) { 279 | curNodeObjId = await _getObjectIdByPath( 280 | reqData, 281 | curNodePathVal, 282 | change.serviceEntityPath 283 | ) 284 | objectIdCache.set(curNodePathVal, curNodeObjId) 285 | } 286 | 287 | let parentNodeObjId = objectIdCache.get(parentNodePathVal) 288 | if (!parentNodeObjId && parentNodePathVal) { 289 | parentNodeObjId = await _getObjectIdByPath( 290 | reqData, 291 | parentNodePathVal, 292 | change.serviceEntityPath 293 | ) 294 | objectIdCache.set(parentNodePathVal, parentNodeObjId) 295 | } 296 | 297 | change.entityID = curNodeObjId 298 | change.parentEntityID = parentNodeObjId 299 | change.parentKey = getUUIDFromPathVal(parentNodePathVal) 300 | } 301 | } 302 | 303 | const _isCompositionContextPath = function (aPath, hasComp) { 304 | if (!aPath) return 305 | if (typeof aPath === 'string') aPath = aPath.split('/') 306 | if (aPath.length < 2) return false 307 | const target = getEntityByContextPath(aPath, hasComp) 308 | const parent = getEntityByContextPath(aPath.slice(0, -1), hasComp) 309 | if (!parent.compositions) return false 310 | return Object.values(parent.compositions).some(c => c._target === target) 311 | } 312 | 313 | const _formatChangeLog = async function (changes, req) { 314 | await _formatObjectID(changes, req.data) 315 | await _formatAssociationContext(changes, req.data) 316 | await _formatCompositionContext(changes, req.data) 317 | } 318 | 319 | const _afterReadChangeView = function (data, req) { 320 | if (!data) return 321 | if (!Array.isArray(data)) data = [data] 322 | localizeLogFields(data, req.locale) 323 | } 324 | 325 | 326 | function _trackedChanges4 (srv, target, diff) { 327 | const template = getTemplate("change-logging", srv, target, { pick: e => e['@changelog'] }) 328 | if (!template.elements.size) return 329 | 330 | const changes = [] 331 | diff._path = `${target.name}(${diff.ID})` 332 | 333 | templateProcessor({ 334 | template, row: diff, processFn: ({ row, key, element }) => { 335 | const from = row._old?.[key] 336 | const to = row[key] 337 | const eleParentKeys = element.parent.keys 338 | if (from === to) return 339 | 340 | /** 341 | * 342 | * HANA driver always filling up the defined decimal places with zeros, 343 | * need to skip the change log if the value is not changed. 344 | * Example: 345 | * entity Books : cuid { 346 | * price : Decimal(11, 4); 347 | * } 348 | * When price is updated from 3000.0000 to 3000, 349 | * the change log should not be created. 350 | */ 351 | if ( 352 | row._op === "update" && 353 | element.type === "cds.Decimal" && 354 | cds.db.kind === "hana" && 355 | typeof to === "number" 356 | ) { 357 | const scaleNum = element.scale || 0; 358 | if (from === formatDecimal(to, scaleNum)) 359 | return; 360 | } 361 | 362 | /** 363 | * 364 | * For the Inline entity such as Items, 365 | * further filtering is required on the keys 366 | * within the 'association' and 'foreign key' to ultimately retain the keys of the entity itself. 367 | * entity Order : cuid { 368 | * title : String; 369 | * Items : Composition of many { 370 | * key ID : UUID; 371 | * quantity : Integer; 372 | * } 373 | * } 374 | */ 375 | const keys = Object.keys(eleParentKeys) 376 | .filter(k => k !== "IsActiveEntity") 377 | .filter(k => eleParentKeys[k]?.type !== "cds.Association") // Skip association 378 | .filter(k => !eleParentKeys[k]?.["@odata.foreignKey4"]) // Skip foreign key 379 | .map(k => `${k}=${row[k]}`) 380 | .join(', ') 381 | 382 | changes.push({ 383 | serviceEntityPath: row._path, 384 | entity: getDBEntity(element.parent).name, 385 | serviceEntity: element.parent.name, 386 | attribute: element["@odata.foreignKey4"] || key, 387 | valueChangedFrom: from?? '', 388 | valueChangedTo: to?? '', 389 | valueDataType: element.type, 390 | modification: row._op, 391 | keys, 392 | }) 393 | } 394 | }) 395 | 396 | return changes.length && changes 397 | } 398 | 399 | const _prepareChangeLogForComposition = async function (entity, entityKey, changes, req) { 400 | const rootEntityPathVals = _getRootEntityPathVals(req.context, entity, entityKey) 401 | 402 | if (rootEntityPathVals.length < 2) { 403 | LOG.info("Parent entity doesn't exist.") 404 | return 405 | } 406 | 407 | const parentEntityPathVal = rootEntityPathVals[rootEntityPathVals.length - 2] 408 | const parentKey = getUUIDFromPathVal(parentEntityPathVal) 409 | const serviceEntityPath = rootEntityPathVals.join('/') 410 | const parentServiceEntityPath = _getAllPathVals(req.context) 411 | .slice(0, rootEntityPathVals.length - 2) 412 | .join('/') 413 | 414 | for (const change of changes) { 415 | change.parentEntityID = await _getObjectIdByPath(req.data, parentEntityPathVal, parentServiceEntityPath) 416 | change.parentKey = parentKey 417 | change.serviceEntityPath = serviceEntityPath 418 | } 419 | 420 | const rootEntity = getNameFromPathVal(rootEntityPathVals[0]) 421 | const rootEntityID = getUUIDFromPathVal(rootEntityPathVals[0]) 422 | return [ rootEntity, rootEntityID ] 423 | } 424 | 425 | async function generatePathAndParams (req, entityKey) { 426 | const { target, data } = req; 427 | const { ID, foreignKey, parentEntity } = getAssociationDetails(target); 428 | const hasParentAndForeignKey = parentEntity && data[foreignKey]; 429 | const targetEntity = hasParentAndForeignKey ? parentEntity : target; 430 | const targetKey = hasParentAndForeignKey ? data[foreignKey] : entityKey; 431 | 432 | let compContext = { 433 | path: hasParentAndForeignKey 434 | ? `${parentEntity.name}/${target.name}` 435 | : `${target.name}`, 436 | params: hasParentAndForeignKey 437 | ? [{ [ID]: data[foreignKey] }, { [ID]: entityKey }] 438 | : [{ [ID]: entityKey }], 439 | hasComp: true 440 | }; 441 | 442 | if (hasParentAndForeignKey && parentEntity[isRoot]) { 443 | return compContext; 444 | } 445 | 446 | let parentAssoc = await processEntity(targetEntity, targetKey, compContext); 447 | while (parentAssoc && !parentAssoc.entity[isRoot]) { 448 | parentAssoc = await processEntity( 449 | parentAssoc.entity, 450 | parentAssoc.ID, 451 | compContext 452 | ); 453 | } 454 | return compContext; 455 | } 456 | 457 | async function processEntity (entity, entityKey, compContext) { 458 | const { ID, foreignKey, parentEntity } = getAssociationDetails(entity); 459 | 460 | if (foreignKey && parentEntity) { 461 | const parentResult = 462 | (await SELECT.one 463 | .from(entity.name) 464 | .where({ [ID]: entityKey }) 465 | .columns(foreignKey)) || {}; 466 | const hasForeignKey = parentResult[foreignKey]; 467 | if (!hasForeignKey) return; 468 | compContext.path = `${parentEntity.name}/${compContext.path}`; 469 | compContext.params.unshift({ [ID]: parentResult[foreignKey] }); 470 | return { 471 | entity: parentEntity, 472 | [ID]: hasForeignKey ? parentResult[foreignKey] : undefined 473 | }; 474 | } 475 | } 476 | 477 | function getAssociationDetails (entity) { 478 | if (!entity) return {}; 479 | const assocName = entity['change-tracking-parentEntity']?.associationName; 480 | const assoc = entity.elements[assocName]; 481 | const parentEntity = assoc?._target; 482 | const foreignKey = assoc?.keys?.[0]?.$generatedFieldName; 483 | const ID = assoc?.keys?.[0]?.ref[0] || 'ID'; 484 | return { ID, foreignKey, parentEntity }; 485 | } 486 | 487 | function isEmpty(value) { 488 | return value === null || value === undefined || value === ""; 489 | } 490 | 491 | async function track_changes (req) { 492 | const config = cds.env.requires["change-tracking"]; 493 | 494 | if ( 495 | (req.event === 'UPDATE' && config?.disableUpdateTracking) || 496 | (req.event === 'CREATE' && config?.disableCreateTracking) || 497 | (req.event === 'DELETE' && config?.disableDeleteTracking) 498 | ) { 499 | return; 500 | } 501 | 502 | let diff = await req.diff() 503 | if (!diff) return 504 | 505 | const diffs = Array.isArray(diff) ? diff : [diff]; 506 | const changes = ( 507 | await Promise.all(diffs.map(item => trackChangesForDiff(item, req, this))) 508 | ).filter(Boolean); 509 | 510 | if (changes.length > 0) { 511 | await INSERT.into("sap.changelog.ChangeLog").entries(changes); 512 | } 513 | 514 | } 515 | 516 | async function trackChangesForDiff(diff, req, that){ 517 | let target = req.target 518 | let compContext = null; 519 | let entityKey = diff.ID 520 | const params = convertSubjectToParams(req.subject); 521 | if (req.subject.ref.length === 1 && params.length === 1 && !target[isRoot]) { 522 | compContext = await generatePathAndParams(req, entityKey); 523 | } 524 | let isComposition = _isCompositionContextPath( 525 | compContext?.path || req.path, 526 | compContext?.hasComp 527 | ); 528 | if ( 529 | req.event === "DELETE" && 530 | target[isRoot] && 531 | !cds.env.requires["change-tracking"]?.preserveDeletes 532 | ) { 533 | await DELETE.from(`sap.changelog.ChangeLog`).where({ entityKey }); 534 | return; 535 | } 536 | 537 | let changes = _trackedChanges4(that, target, diff) 538 | if (!changes) return 539 | 540 | await _formatChangeLog(changes, req) 541 | if (isComposition) { 542 | let reqInfo = { 543 | data: req.data, 544 | context: { 545 | path: compContext?.path || req.path, 546 | params: compContext?.params || params, 547 | event: req.event, 548 | hasComp: compContext?.hasComp 549 | } 550 | }; 551 | [ target, entityKey ] = await _prepareChangeLogForComposition(target, entityKey, changes, reqInfo) 552 | } 553 | const dbEntity = getDBEntity(target) 554 | return { 555 | entity: dbEntity.name, 556 | entityKey: entityKey, 557 | serviceEntity: target.name || target, 558 | changes: changes.filter(c => !isEmpty(c.valueChangedFrom) || !isEmpty(c.valueChangedTo)).map((c) => ({ 559 | ...c, 560 | valueChangedFrom: `${c.valueChangedFrom ?? ''}`, 561 | valueChangedTo: `${c.valueChangedTo ?? ''}`, 562 | })), 563 | }; 564 | } 565 | 566 | module.exports = { track_changes, _afterReadChangeView } 567 | -------------------------------------------------------------------------------- /lib/entity-helper.js: -------------------------------------------------------------------------------- 1 | const cds = require("@sap/cds") 2 | const LOG = cds.log("change-log") 3 | 4 | 5 | const getNameFromPathVal = function (pathVal) { 6 | return /^(.+?)\(/.exec(pathVal)?.[1] || "" 7 | } 8 | 9 | const getUUIDFromPathVal = function (pathVal) { 10 | const regRes = /\((.+?)\)/.exec(pathVal) 11 | return regRes ? regRes[1] : "" 12 | } 13 | 14 | const getEntityByContextPath = function (aPath, hasComp = false) { 15 | if (hasComp) return cds.model.definitions[aPath[aPath.length - 1]] 16 | let entity = cds.model.definitions[aPath[0]] 17 | for (let each of aPath.slice(1)) { 18 | entity = entity.elements[each]?._target 19 | } 20 | return entity 21 | } 22 | 23 | const getObjIdElementNamesInArray = function (elements) { 24 | if (Array.isArray(elements)) return elements.map(e => { 25 | const splitted = (e["="]||e).split('.') 26 | splitted.shift() 27 | return splitted.join('.') 28 | }) 29 | else return [] 30 | } 31 | 32 | const getCurObjFromDbQuery = async function (entityName, queryVal, /**optional*/ queryKey='ID') { 33 | if (!queryVal) return {} 34 | // REVISIT: This always reads all elements -> should read required ones only! 35 | const obj = await SELECT.one.from(entityName).where({[queryKey]: queryVal}) 36 | return obj || {} 37 | } 38 | 39 | const getCurObjFromReqData = function (reqData, nodePathVal, pathVal) { 40 | const pathVals = pathVal.split('/') 41 | const rootNodePathVal = pathVals[0] 42 | let curReqObj = reqData || {} 43 | 44 | if (nodePathVal === rootNodePathVal) return curReqObj 45 | else pathVals.shift() 46 | 47 | let parentSrvObjName = getNameFromPathVal(rootNodePathVal) 48 | 49 | for (const subNodePathVal of pathVals) { 50 | const srvObjName = getNameFromPathVal(subNodePathVal) 51 | const curSrvObjUUID = getUUIDFromPathVal(subNodePathVal) 52 | const associationName = _getAssociationName(parentSrvObjName, srvObjName) 53 | if (curReqObj) { 54 | let associationData = curReqObj[associationName] 55 | if (!Array.isArray(associationData)) associationData = [associationData] 56 | curReqObj = associationData?.find(x => x?.ID === curSrvObjUUID) || {} 57 | } 58 | if (subNodePathVal === nodePathVal) return curReqObj || {} 59 | parentSrvObjName = srvObjName 60 | } 61 | 62 | return curReqObj 63 | 64 | function _getAssociationName(entity, target) { 65 | const source = cds.model.definitions[entity] 66 | const assocs = source.associations 67 | for (const each in assocs) { 68 | if (assocs[each].target === target) return each 69 | } 70 | } 71 | } 72 | 73 | 74 | async function getObjectId (reqData, entityName, fields, curObj) { 75 | let all = [], { curObjFromReqData: req_data={}, curObjFromDbQuery: db_data={} } = curObj 76 | let entity = cds.model.definitions[entityName] 77 | if (!fields?.length) fields = entity["@changelog"]?.map?.(k => k['='] || k) || [] 78 | for (let field of fields) { 79 | let path = field.split('.') 80 | if (path.length > 1) { 81 | let current = entity, _db_data = db_data 82 | while (path.length > 1) { 83 | let assoc = current.elements[path[0]]; if (!assoc?.isAssociation) break 84 | let foreignKey = assoc.keys?.[0]?.$generatedFieldName 85 | let IDval = 86 | req_data[foreignKey] && current.name === entityName 87 | ? req_data[foreignKey] 88 | : _db_data[foreignKey] 89 | if (!IDval) { 90 | _db_data = {}; 91 | } else try { 92 | // REVISIT: This always reads all elements -> should read required ones only! 93 | let ID = assoc.keys?.[0]?.ref[0] || 'ID' 94 | const isComposition = hasComposition(assoc._target, current) 95 | // Peer association and composition are distinguished by the value of isComposition. 96 | if (isComposition) { 97 | // This function can recursively retrieve the desired information from reqData without having to read it from db. 98 | _db_data = _getCompositionObjFromReq(reqData, IDval) 99 | // When multiple layers of child nodes are deleted at the same time, the deep layer of child nodes will lose the information of the upper nodes, so data needs to be extracted from the db. 100 | const entityKeys = reqData ? Object.keys(reqData).filter(item => !Object.keys(assoc._target.keys).some(ele => item === ele)) : []; 101 | if (!_db_data || JSON.stringify(_db_data) === '{}' || entityKeys.length === 0) { 102 | _db_data = await getCurObjFromDbQuery(assoc._target, IDval, ID); 103 | } 104 | } else { 105 | _db_data = await getCurObjFromDbQuery(assoc._target, IDval, ID); 106 | } 107 | } catch (e) { 108 | LOG.error("Failed to generate object Id for an association entity.", e) 109 | throw new Error("Failed to generate object Id for an association entity.", e) 110 | } 111 | current = assoc._target 112 | path.shift() 113 | } 114 | field = path.join('_') 115 | let obj = current.name === entityName && req_data[field] ? req_data[field] : _db_data[field] 116 | if (obj) all.push(obj) 117 | } else { 118 | let e = entity.elements[field] 119 | if (e?.isAssociation) field = e.keys?.[0]?.$generatedFieldName 120 | let obj = req_data[field] || db_data[field] 121 | if (obj) all.push(obj) 122 | } 123 | } 124 | return all.join(', ') 125 | } 126 | 127 | 128 | 129 | const getDBEntity = (entity) => { 130 | if (typeof entity === 'string') entity = cds.model.definitions[entity] 131 | let proto = Reflect.getPrototypeOf(entity) 132 | if (proto instanceof cds.entity) return proto 133 | } 134 | 135 | const getValueEntityType = function (entityName, fields) { 136 | const types=[], entity = cds.model.definitions[entityName] 137 | for (let field of fields) { 138 | let current = entity, path = field.split('.') 139 | if (path.length > 1) { 140 | for (;;) { 141 | let target = current.elements[path[0]]?._target 142 | if (target) current = target; else break 143 | path.shift() 144 | } 145 | field = path.join('_') 146 | } 147 | let e = current.elements[field] 148 | if (e) types.push(e.type) 149 | } 150 | return types.join(', ') 151 | } 152 | 153 | const hasComposition = function (parentEntity, subEntity) { 154 | if (!parentEntity.compositions) { 155 | return false 156 | } 157 | 158 | const compositions = Object.values(parentEntity.compositions); 159 | 160 | for (const composition of compositions) { 161 | if (composition.target === subEntity.name) { 162 | return true; 163 | } 164 | } 165 | 166 | return false 167 | } 168 | 169 | const _getCompositionObjFromReq = function (obj, targetID) { 170 | if (obj?.ID === targetID) { 171 | return obj; 172 | } 173 | 174 | for (const key in obj) { 175 | if (typeof obj[key] === "object" && obj[key] !== null) { 176 | const result = _getCompositionObjFromReq(obj[key], targetID); 177 | if (result) { 178 | return result; 179 | } 180 | } 181 | } 182 | 183 | return null; 184 | }; 185 | 186 | module.exports = { 187 | getCurObjFromReqData, 188 | getCurObjFromDbQuery, 189 | getObjectId, 190 | getNameFromPathVal, 191 | getUUIDFromPathVal, 192 | getDBEntity, 193 | getEntityByContextPath, 194 | getObjIdElementNamesInArray, 195 | getValueEntityType, 196 | } 197 | -------------------------------------------------------------------------------- /lib/localization.js: -------------------------------------------------------------------------------- 1 | const cds = require("@sap/cds/lib"); 2 | const LOG = cds.log("change-log"); 3 | const { getNameFromPathVal, getDBEntity } = require("./entity-helper"); 4 | 5 | const MODIF_I18N_MAP = { 6 | create: "{i18n>ChangeLog.modification.create}", 7 | update: "{i18n>ChangeLog.modification.update}", 8 | delete: "{i18n>ChangeLog.modification.delete}", 9 | }; 10 | 11 | const _getLocalization = function (locale, i18nKey) { 12 | // 13 | // 14 | // 15 | // 16 | // REVISIT! 17 | // REVISIT! 18 | // REVISIT! 19 | // REVISIT! 20 | // REVISIT! 21 | // 22 | // 23 | // 24 | // 25 | return JSON.parse(cds.localize(cds.model, locale, JSON.stringify(i18nKey))); 26 | }; 27 | 28 | const _localizeModification = function (change, locale) { 29 | if (change.modification && MODIF_I18N_MAP[change.modification]) { 30 | change.modification = _getLocalization(locale, MODIF_I18N_MAP[change.modification]); 31 | } 32 | }; 33 | 34 | const _localizeDefaultObjectID = function (change, locale) { 35 | if (!change.objectID) { 36 | change.objectID = change.entity ? change.entity : ""; 37 | } 38 | if (change.objectID && change.serviceEntityPath && !change.parentObjectID && change.parentKey) { 39 | const path = change.serviceEntityPath.split('/'); 40 | const parentNodePathVal = path[path.length - 2]; 41 | const parentEntityName = getNameFromPathVal(parentNodePathVal); 42 | const dbEntity = getDBEntity(parentEntityName); 43 | try { 44 | const labelI18nKey = dbEntity['@Common.Label'] || dbEntity['@title']; 45 | const labelI18nValue = labelI18nKey ? _getLocalization(locale, labelI18nKey) : null; 46 | change.parentObjectID = labelI18nValue ? labelI18nValue : dbEntity.name; 47 | } catch (e) { 48 | LOG.error("Failed to localize parent object id", e); 49 | throw new Error("Failed to localize parent object id", e); 50 | } 51 | } 52 | }; 53 | 54 | const _localizeEntityType = function (change, locale) { 55 | if (change.entity) { 56 | try { 57 | const labelI18nKey = _getLabelI18nKeyOnEntity(change.serviceEntity); 58 | const labelI18nValue = labelI18nKey ? _getLocalization(locale, labelI18nKey) : null; 59 | 60 | change.entity = labelI18nValue ? labelI18nValue : change.entity; 61 | } catch (e) { 62 | LOG.error("Failed to localize entity type", e); 63 | throw new Error("Failed to localize entity type", e); 64 | } 65 | } 66 | if (change.serviceEntity) { 67 | try { 68 | const labelI18nKey = _getLabelI18nKeyOnEntity(change.serviceEntity); 69 | const labelI18nValue = labelI18nKey ? _getLocalization(locale, labelI18nKey) : null; 70 | 71 | change.serviceEntity = labelI18nValue ? labelI18nValue : change.serviceEntity; 72 | } catch (e) { 73 | LOG.error("Failed to localize service entity", e); 74 | throw new Error("Failed to localize service entity", e); 75 | } 76 | } 77 | }; 78 | 79 | const _localizeAttribute = function (change, locale) { 80 | if (change.attribute && change.serviceEntity) { 81 | try { 82 | const serviceEntity = cds.model.definitions[change.serviceEntity]; 83 | let labelI18nKey = _getLabelI18nKeyOnEntity(change.serviceEntity, change.attribute); 84 | if (!labelI18nKey) { 85 | const element = serviceEntity.elements[change.attribute]; 86 | if (element.isAssociation) labelI18nKey = _getLabelI18nKeyOnEntity(element.target); 87 | } 88 | const labelI18nValue = labelI18nKey ? _getLocalization(locale, labelI18nKey) : null; 89 | change.attribute = labelI18nValue ? labelI18nValue : change.attribute; 90 | } catch (e) { 91 | LOG.error("Failed to localize change attribute", e); 92 | throw new Error("Failed to localize change attribute", e); 93 | } 94 | } 95 | }; 96 | 97 | const _getLabelI18nKeyOnEntity = function (entityName, /** optinal */ attribute) { 98 | let def = cds.model.definitions[entityName]; 99 | if (attribute) def = def?.elements[attribute] 100 | if (!def) return ""; 101 | return def['@Common.Label'] || def['@title'] || def['@UI.HeaderInfo.TypeName']; 102 | }; 103 | 104 | const localizeLogFields = function (data, locale) { 105 | if (!locale) return 106 | for (const change of data) { 107 | _localizeModification(change, locale); 108 | _localizeAttribute(change, locale); 109 | _localizeEntityType(change, locale); 110 | _localizeDefaultObjectID(change, locale); 111 | } 112 | }; 113 | module.exports = { 114 | localizeLogFields, 115 | }; 116 | -------------------------------------------------------------------------------- /lib/template-processor.js: -------------------------------------------------------------------------------- 1 | // Enhanced class based on cds v5.5.5 @sap/cds/libx/_runtime/common/utils/templateProcessor 2 | 3 | const DELIMITER = require("@sap/cds/libx/_runtime/common/utils/templateDelimiter"); 4 | 5 | const _formatRowContext = (tKey, keyNames, row) => { 6 | const keyValuePairs = keyNames.map((key) => `${key}=${row[key]}`); 7 | const keyValuePairsSerialized = keyValuePairs.join(","); 8 | return `${tKey}(${keyValuePairsSerialized})`; 9 | }; 10 | 11 | const _processElement = (processFn, row, key, elements, isRoot, pathSegments, picked = {}) => { 12 | const element = elements[key]; 13 | const { plain } = picked; 14 | 15 | // do not change-track personal data 16 | const isPersonalData = element && Object.keys(element).some(key => key.startsWith('@PersonalData')); 17 | if (plain && !isPersonalData) { 18 | /** 19 | * @type import('../../types/api').templateProcessorProcessFnArgs 20 | */ 21 | const elementInfo = { row, key, element, plain, isRoot, pathSegments }; 22 | processFn(elementInfo); 23 | } 24 | }; 25 | 26 | const _processRow = (processFn, row, template, tKey, tValue, isRoot, pathOptions) => { 27 | const { template: subTemplate, picked } = tValue; 28 | const key = tKey.split(DELIMITER).pop(); 29 | const { segments: pathSegments } = pathOptions; 30 | 31 | if (!subTemplate && pathSegments) { 32 | pathSegments.push(key); 33 | } 34 | 35 | _processElement(processFn, row, key, template.target.elements, isRoot, pathSegments, picked); 36 | 37 | // process deep 38 | if (subTemplate) { 39 | let subRows = row && row[key]; 40 | 41 | subRows = Array.isArray(subRows) ? subRows : [subRows]; 42 | 43 | // Build entity path 44 | subRows.forEach((subRow) => { 45 | if (subRow && row && row._path) { 46 | /** Enhancement by SME: Support CAP Change Histroy 47 | * Construct path from root entity to current entity. 48 | */ 49 | const serviceNodeName = template.target.elements[key].target; 50 | subRow._path = `${row._path}/${serviceNodeName}(${subRow.ID})`; 51 | } 52 | }); 53 | 54 | _processComplex(processFn, subRows, subTemplate, key, pathOptions); 55 | } 56 | }; 57 | 58 | const _processComplex = (processFn, rows, template, tKey, pathOptions) => { 59 | if (rows.length === 0) { 60 | return; 61 | } 62 | 63 | const segments = pathOptions.segments; 64 | let keyNames; 65 | 66 | for (const row of rows) { 67 | if (row == null) { 68 | continue; 69 | } 70 | 71 | const args = { processFn, row, template, isRoot: false, pathOptions }; 72 | 73 | if (pathOptions.includeKeyValues) { 74 | keyNames = keyNames || (template.target.keys && Object.keys(template.target.keys)) || []; 75 | pathOptions.rowKeysGenerator(keyNames, row, template); 76 | const pathSegment = _formatRowContext(tKey, keyNames, { ...row, ...pathOptions.extraKeys }); 77 | args.pathOptions.segments = segments ? [...segments, pathSegment] : [pathSegment]; 78 | } 79 | 80 | templateProcessor(args); 81 | } 82 | }; 83 | 84 | /** 85 | * @param {import("../../types/api").TemplateProcessor} args 86 | */ 87 | const templateProcessor = ({ processFn, row, template, isRoot = true, pathOptions = {} }) => { 88 | const segments = pathOptions.segments && [...pathOptions.segments]; 89 | 90 | for (const [tKey, tValue] of template.elements) { 91 | if (segments) { 92 | pathOptions.segments = [...segments]; 93 | } 94 | _processRow(processFn, row, template, tKey, tValue, isRoot, pathOptions); 95 | } 96 | }; 97 | 98 | module.exports = templateProcessor; 99 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cap-js/change-tracking", 3 | "version": "1.0.8", 4 | "description": "CDS plugin providing out-of-the box support for automatic capturing, storing, and viewing of the change records of modeled entities.", 5 | "repository": "cap-js/change-tracking", 6 | "author": "SAP SE (https://www.sap.com)", 7 | "license": "Apache-2.0", 8 | "main": "cds-plugin.js", 9 | "files": [ 10 | "lib", 11 | "_i18n", 12 | "index.cds", 13 | "CHANGELOG.md", 14 | "README.md" 15 | ], 16 | "scripts": { 17 | "lint": "npx eslint .", 18 | "test": "npx jest --silent" 19 | }, 20 | "peerDependencies": { 21 | "@sap/cds": ">=8" 22 | }, 23 | "devDependencies": { 24 | "@cap-js/change-tracking": "file:.", 25 | "@cap-js/sqlite": "^1 || ^2", 26 | "@cap-js/cds-test": "*", 27 | "express": "^4" 28 | }, 29 | "cds": { 30 | "requires": { 31 | "change-tracking": { 32 | "model": "@cap-js/change-tracking" 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/bookshop/db/_i18n/i18n.properties: -------------------------------------------------------------------------------- 1 | ## Authors 2 | #XTIT 3 | authors.objectTitle=Author 4 | #XTIT 5 | authors.name=Name 6 | #XTIT 7 | authors.placeOfBirth=Place Of Birth 8 | 9 | ## Books 10 | #XTIT 11 | books.objectTitle=Book 12 | #XTIT 13 | books.title=Title 14 | #XTIT 15 | books.descr=Description 16 | #XTIT 17 | books.genre=Genres 18 | #XTIT 19 | books.bookType=Book Types 20 | 21 | ## Volumns 22 | #XTIT 23 | volumns.objectTitle=Volumn 24 | #XTIT 25 | volumns.title=Title 26 | #XTIT 27 | volumns.sequence=Sequence 28 | 29 | ## BookStore 30 | #XTIT 31 | bookStore.objectTitle=Book Store 32 | #XTIT 33 | bookStore.name=Book Store Name 34 | #XTIT 35 | bookStore.location=Book Store Location 36 | #XTIT 37 | bookStore.books=Books 38 | #XTIT 39 | bookStore.lifecycleStatus=Lifecycle Status 40 | #XTIT 41 | bookStore.city=City 42 | #XTIT 43 | bookStore.registry=Registry 44 | 45 | ## Service Authors 46 | #XTIT 47 | serviceAuthors.name=Author Name 48 | 49 | ## Bookstore Registry 50 | #XTIT 51 | bookStoreRegistry.objectTitle=Book Store Registry 52 | bookStoreRegistry.code=Code 53 | bookStoreRegistry.validOn=Valid On 54 | 55 | ## RootEntity 56 | #XTIT 57 | RootEntity.objectTitle= Root Entity 58 | 59 | ## Level1Entity 60 | #XTIT 61 | Level1Entity.objectTitle=Level1 Entity 62 | 63 | ## Level2Entity 64 | #XTIT 65 | Level2Entity.objectTitle=Level2 Entity 66 | 67 | ## Level3Entity 68 | #XTIT 69 | Level3Entity.objectTitle=Level3 Entity 70 | 71 | ## Schools 72 | #XTIT 73 | Schools.name=Name 74 | 75 | ## Classes 76 | #XTIT 77 | Classes.name=Name 78 | Classes.teacher=Teacher 79 | -------------------------------------------------------------------------------- /tests/bookshop/db/codelists.cds: -------------------------------------------------------------------------------- 1 | using {sap.common.CodeList as CodeList} from '@sap/cds/common'; 2 | 3 | namespace sap.capire.bookshop; 4 | 5 | entity PaymentAgreementStatusCodes : CodeList { 6 | key code : String(10); 7 | } 8 | 9 | entity ActivationStatusCode : CodeList { 10 | key code : String(20); 11 | } 12 | -------------------------------------------------------------------------------- /tests/bookshop/db/common/codeLists.cds: -------------------------------------------------------------------------------- 1 | using {sap.common.CodeList as CodeList} from '@sap/cds/common'; 2 | 3 | namespace sap.capire.common.codelists; 4 | 5 | entity LifecycleStatusCodes : CodeList { 6 | key code : String(2); 7 | criticality : Integer; 8 | } 9 | 10 | entity BookTypeCodes : CodeList { 11 | key code : String(3); 12 | } 13 | 14 | entity ActivationStatusCode : CodeList { 15 | key code : String(20); 16 | } 17 | -------------------------------------------------------------------------------- /tests/bookshop/db/common/types.cds: -------------------------------------------------------------------------------- 1 | using {sap.capire.common.codelists as codeLists} from '../common/codeLists'; 2 | 3 | namespace sap.capire.common.types; 4 | 5 | type PersonName { 6 | firstName : String; 7 | lastName : String; 8 | } 9 | 10 | type CountryName { 11 | name : String; 12 | code : String; 13 | } 14 | 15 | type LifecycleStatusCode : Association to one codeLists.LifecycleStatusCodes; 16 | type BookTypeCodes : Association to one codeLists.BookTypeCodes; 17 | -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.bookshop-ActivationStatusCode.csv: -------------------------------------------------------------------------------- 1 | code;name 2 | INACTIVE;Inactive 3 | ACTIVE;Active 4 | -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.bookshop-AssocOne.csv: -------------------------------------------------------------------------------- 1 | ID;name;info_ID 2 | bc21e0d9-a313-4f52-8336-c1be5f88c346;Mission1;bc21e0d9-a313-4f52-8336-c3da5f66d537 3 | bc21e0d9-a313-4f52-8336-c1be5f55d137;Mission2;bc21e0d9-a313-4f52-8336-d4ad6c55d563 4 | bc21e0d9-a313-4f52-8336-c1be5f44f435;Mission3;bc21e0d9-a313-4f52-8336-b5fa4d22a123 5 | -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.bookshop-AssocThree.csv: -------------------------------------------------------------------------------- 1 | ID;name 2 | bc21e0d9-a313-4f52-8336-a4eb6d55c137;Super Mario1 3 | bc21e0d9-a313-4f52-8336-a2dcec6d33f541;Super Mario2 4 | bc21e0d9-a313-4f52-8336-d3da5a66c153;Super Mario3 5 | -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.bookshop-AssocTwo.csv: -------------------------------------------------------------------------------- 1 | ID;name;info_ID 2 | bc21e0d9-a313-4f52-8336-c3da5f66d537;Track1;bc21e0d9-a313-4f52-8336-a4eb6d55c137 3 | c21e0d9-a313-4f52-8336-d4ad6c55d563;Track2;bc21e0d9-a313-4f52-8336-a2dcec6d33f541 4 | bc21e0d9-a313-4f52-8336-b5fa4d22a123;Track3;bc21e0d9-a313-4f52-8336-d3da5a66c153 5 | -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.bookshop-Authors.csv: -------------------------------------------------------------------------------- 1 | ID;name_firstName;name_lastName;dateOfBirth;placeOfBirth;dateOfDeath;placeOfDeath 2 | d4d4a1b3-5b83-4814-8a20-f039af6f0387;Emily;Brontë;1818-07-30;Thornton, Yorkshire;1848-12-19;Haworth, Yorkshire 3 | 47f97f40-4f41-488a-b10b-a5725e762d5e;Charlotte;Brontë;1818-04-21;Thornton, Yorkshire;1855-03-31;Haworth, Yorkshire 4 | 5c30d395-db0a-4095-bd7e-d4de34646607;Edgar Allen;Poe;1809-01-19;Boston, Massachusetts;1849-10-07;Baltimore, Maryland 5 | a45da28a-7f55-4b53-8a63-48b61132d1b9;Richard;Carpenter;1929-08-14;King’s Lynn, Norfolk;2012-02-26;Hertfordshire, England -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.bookshop-BookStoreRegistry.csv: -------------------------------------------------------------------------------- 1 | ID;code;validOn 2 | 12ed5ac2-d45b-11ed-afa1-0242ac120001;Paris-1;2012-01-01 3 | 12ed5dd8-d45b-11ed-afa1-0242ac120001;New York-1;2022-10-15 4 | 12ed5dd8-d45b-11ed-afa1-0242ac120002;San Francisco-1;2018-09-01 -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.bookshop-BookStores.bookInventory.csv: -------------------------------------------------------------------------------- 1 | ID;up__ID;title 2 | 3ccf474c-3881-44b7-99fb-59a2a4668418;64625905-c234-4d0d-9bc1-283ee8946770;Eleonora 3 | 3583f982-d7df-4aad-ab26-301d4a157cd7;64625905-c234-4d0d-9bc1-283ee8946770;Catweazle 4 | -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.bookshop-BookStores.csv: -------------------------------------------------------------------------------- 1 | ID;name;location;lifecycleStatus_code;city_ID;registry_ID 2 | 64625905-c234-4d0d-9bc1-283ee8946770;Shakespeare and Company;Paris;IP;bc21e0d9-a313-4f52-8336-c1be5f66e257;12ed5ac2-d45b-11ed-afa1-0242ac120001 3 | 5ab2a87b-3a56-4d97-a697-7af72334a384;The Strand;New York City;CL;60b4c55d-ec87-4edc-84cb-2e4ecd60de48;12ed5dd8-d45b-11ed-afa1-0242ac120001 4 | 8aaed432-8336-4b0d-be7e-3ef1ce7f13ea;City Lights Books;San Francisco;AC;2fad60be-6571-4242-ba9e-23976529cc50;12ed5dd8-d45b-11ed-afa1-0242ac120002 -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.bookshop-Books.csv: -------------------------------------------------------------------------------- 1 | ID;title;descr;isUsed;author_ID;stock;price;genre_ID;bookStore_ID 2 | 9d703c23-54a8-4eff-81c1-cdce6b8376b1;Wuthering Heights;"Wuthering Heights, Emily Brontë's only novel, was published in 1847 under the pseudonym ""Ellis Bell"". It was written between October 1845 and June 1846. Wuthering Heights and Anne Brontë's Agnes Grey were accepted by publisher Thomas Newby before the success of their sister Charlotte's novel Jane Eyre. After Emily's death, Charlotte edited the manuscript of Wuthering Heights and arranged for the edited version to be published as a posthumous second edition in 1850.";true;d4d4a1b3-5b83-4814-8a20-f039af6f0387;12;3000.0000;11;64625905-c234-4d0d-9bc1-283ee8946770 3 | 676059d4-8851-47f1-b558-3bdc461bf7d5;Jane Eyre;"Jane Eyre /ɛər/ (originally published as Jane Eyre: An Autobiography) is a novel by English writer Charlotte Brontë, published under the pen name ""Currer Bell"", on 16 October 1847, by Smith, Elder & Co. of London. The first American edition was published the following year by Harper & Brothers of New York. Primarily a bildungsroman, Jane Eyre follows the experiences of its eponymous heroine, including her growth to adulthood and her love for Mr. Rochester, the brooding master of Thornfield Hall. The novel revolutionised prose fiction in that the focus on Jane's moral and spiritual development is told through an intimate, first-person narrative, where actions and events are coloured by a psychological intensity. The book contains elements of social criticism, with a strong sense of Christian morality at its core and is considered by many to be ahead of its time because of Jane's individualistic character and how the novel approaches the topics of class, sexuality, religion and feminism.";true;47f97f40-4f41-488a-b10b-a5725e762d5e;11;12.34;11;5ab2a87b-3a56-4d97-a697-7af72334a384 4 | 42bc7997-f6ce-4ae9-8a64-ee5e02ef1087;The Raven;"""The Raven"" is a narrative poem by American writer Edgar Allan Poe. First published in January 1845, the poem is often noted for its musicality, stylized language, and supernatural atmosphere. It tells of a talking raven's mysterious visit to a distraught lover, tracing the man's slow fall into madness. The lover, often identified as being a student, is lamenting the loss of his love, Lenore. Sitting on a bust of Pallas, the raven seems to further distress the protagonist with its constant repetition of the word ""Nevermore"". The poem makes use of folk, mythological, religious, and classical references.";true;5c30d395-db0a-4095-bd7e-d4de34646607;333;13.13;16;5ab2a87b-3a56-4d97-a697-7af72334a384 5 | 9297e4ea-396e-47a4-8815-cd4622dea8b1;Eleonora;"""Eleonora"" is a short story by Edgar Allan Poe, first published in 1842 in Philadelphia in the literary annual The Gift. It is often regarded as somewhat autobiographical and has a relatively ""happy"" ending.";true;5c30d395-db0a-4095-bd7e-d4de34646607;555;14;16;8aaed432-8336-4b0d-be7e-3ef1ce7f13ea 6 | 574c8add-0ee3-4175-ab62-ca09a92c723c;Catweazle;Catweazle is a British fantasy television series, starring Geoffrey Bayldon in the title role, and created by Richard Carpenter for London Weekend Television. The first series, produced and directed by Quentin Lawrence, was screened in the UK on ITV in 1970. The second series, directed by David Reid and David Lane, was shown in 1971. Each series had thirteen episodes, most but not all written by Carpenter, who also published two books based on the scripts.;true;a45da28a-7f55-4b53-8a63-48b61132d1b9;22;15;13;8aaed432-8336-4b0d-be7e-3ef1ce7f13ea 7 | -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.bookshop-Books_texts.csv: -------------------------------------------------------------------------------- 1 | ID;locale;title;descr 2 | 9d703c23-54a8-4eff-81c1-cdce6b8376b1;de;Sturmhöhe;Sturmhöhe (Originaltitel: Wuthering Heights) ist der einzige Roman der englischen Schriftstellerin Emily Brontë (1818–1848). Der 1847 unter dem Pseudonym Ellis Bell veröffentlichte Roman wurde vom viktorianischen Publikum weitgehend abgelehnt, heute gilt er als ein Klassiker der britischen Romanliteratur des 19. Jahrhunderts. 3 | 9d703c23-54a8-4eff-81c1-cdce6b8376b1;fr;Les Hauts de Hurlevent;Les Hauts de Hurlevent (titre original : Wuthering Heights), parfois orthographié Les Hauts de Hurle-Vent, est l'unique roman d'Emily Brontë, publié pour la première fois en 1847 sous le pseudonyme d’Ellis Bell. Loin d'être un récit moralisateur, Emily Brontë achève néanmoins le roman dans une atmosphère sereine, suggérant le triomphe de la paix et du Bien sur la vengeance et le Mal. 4 | 676059d4-8851-47f1-b558-3bdc461bf7d5;de;Jane Eyre;Jane Eyre. Eine Autobiographie (Originaltitel: Jane Eyre. An Autobiography), erstmals erschienen im Jahr 1847 unter dem Pseudonym Currer Bell, ist der erste veröffentlichte Roman der britischen Autorin Charlotte Brontë und ein Klassiker der viktorianischen Romanliteratur des 19. Jahrhunderts. Der Roman erzählt in Form einer Ich-Erzählung die Lebensgeschichte von Jane Eyre (ausgesprochen /ˌdʒeɪn ˈɛə/), die nach einer schweren Kindheit eine Stelle als Gouvernante annimmt und sich in ihren Arbeitgeber verliebt, jedoch immer wieder um ihre Freiheit und Selbstbestimmung kämpfen muss. Als klein, dünn, blass, stets schlicht dunkel gekleidet und mit strengem Mittelscheitel beschrieben, gilt die Heldin des Romans Jane Eyre nicht zuletzt aufgrund der Kino- und Fernsehversionen der melodramatischen Romanvorlage als die bekannteste englische Gouvernante der Literaturgeschichte 5 | 9297e4ea-396e-47a4-8815-cd4622dea8b1;de;Eleonora;“Eleonora” ist eine Erzählung von Edgar Allan Poe. Sie wurde 1841 erstveröffentlicht. In ihr geht es um das Paradox der Treue in der Treulosigkeit. -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.bookshop-City.csv: -------------------------------------------------------------------------------- 1 | ID;name;country_ID 2 | bc21e0d9-a313-4f52-8336-c1be5f66e257;Paris;31f8b3fb-37f4-4222-93ac-96e377c06f82 3 | 60b4c55d-ec87-4edc-84cb-2e4ecd60de48;New York;b0f94ce1-668d-49b3-8a01-763b30baee9b 4 | 2fad60be-6571-4242-ba9e-23976529cc50;San Francisco;b0f94ce1-668d-49b3-8a01-763b30baee9b -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.bookshop-Country.csv: -------------------------------------------------------------------------------- 1 | ID;countryName_name;countryName_code 2 | 31f8b3fb-37f4-4222-93ac-96e377c06f82;France;FR 3 | b0f94ce1-668d-49b3-8a01-763b30baee9b;United States;USA -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.bookshop-Customers.csv: -------------------------------------------------------------------------------- 1 | ID;name;city;country;age; 2 | d4d4a1b3-5b83-4814-8a20-f039af6f0385;Seven;Shanghai;China;25 3 | 47f97f40-4f41-488a-b10b-a5725e762d57;Honda;Ōsaka;Japan;30 4 | 5c30d395-db0a-4095-bd7e-d4de3464660a;Dylan;Dallas;America;35 5 | a45da28a-7f55-4b53-8a63-48b61132d1bb;Richard;Frankfurt;German;41 -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.bookshop-Genres.csv: -------------------------------------------------------------------------------- 1 | ID;parent_ID;name 2 | 10;;Fiction 3 | 11;10;Drama 4 | 12;10;Poetry 5 | 13;10;Fantasy 6 | 14;10;Science Fiction 7 | 15;10;Romance 8 | 16;10;Mystery 9 | 17;10;Thriller 10 | 18;10;Dystopia 11 | 19;10;Fairy Tale 12 | 20;;Non-Fiction 13 | 21;20;Biography 14 | 22;21;Autobiography 15 | 23;20;Essay 16 | 24;20;Speech 17 | -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.bookshop-Level1Entity.csv: -------------------------------------------------------------------------------- 1 | ID;title;parent_ID 2 | 9d703c23-54a8-4eff-81c1-cdce6b8376b1;Level1 Wuthering Heights;64625905-c234-4d0d-9bc1-283ee8940812 3 | 676059d4-8851-47f1-b558-3bdc461bf7d5;Level1 Jane Eyre;5ab2a87b-3a56-4d97-a697-7af72334b123 4 | 42bc7997-f6ce-4ae9-8a64-ee5e02ef1087;Level1 The Raven;5ab2a87b-3a56-4d97-a697-7af72334b213 5 | 9297e4ea-396e-47a4-8815-cd4622dea8b1;Level1 Eleonora;8aaed432-8336-4b0d-be7e-3ef1ce7f14dc 6 | 574c8add-0ee3-4175-ab62-ca09a92c723c;Level1 Catweazle;8aaed432-8336-4b0d-be7e-3ef1ce7f14dc 7 | -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.bookshop-Level1Object.csv: -------------------------------------------------------------------------------- 1 | ID;title;parent_ID 2 | 9a61178f-bfb3-4c17-8d17-c6b4a63e0802;Level1Object title1;0a41a187-a2ff-4df6-bd12-fae8996e7e28 3 | ae0d8b10-84cf-4777-a489-a198d1717c75;Level1Object title2;6ac4afbf-deda-45ae-88e6-2883157cd576 4 | -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.bookshop-Level2Entity.csv: -------------------------------------------------------------------------------- 1 | ID;title;parent_ID 2 | dd1fdd7d-da2a-4600-940b-0baf2946c4ff;Level2 The title;9d703c23-54a8-4eff-81c1-cdce6b8376b1 3 | -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.bookshop-Level2Object.csv: -------------------------------------------------------------------------------- 1 | ID;title;parent_ID 2 | a40a9fd8-573d-4f41-1111-fa8ea0d8b1bc;Level2Object title1;9a61178f-bfb3-4c17-8d17-c6b4a63e0802 3 | 55bb60e4-ed86-46e6-9378-346153eba8d4;Level2Object title2;ae0d8b10-84cf-4777-a489-a198d1717c75 4 | -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.bookshop-Level3Entity.csv: -------------------------------------------------------------------------------- 1 | ID;title;parent_ID 2 | dd1fdd7d-da2a-4600-940b-1cdd2946c4ff;Level3 The Hope;dd1fdd7d-da2a-4600-940b-0baf2946c4ff 3 | -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.bookshop-Level3Object.csv: -------------------------------------------------------------------------------- 1 | ID;title;parent_ID 2 | a40a9fd8-573d-4f41-1111-fb8ea0d8c5cc;Level3Object title;a40a9fd8-573d-4f41-1111-fa8ea0d8b1bc 3 | -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.bookshop-Order.Items.csv: -------------------------------------------------------------------------------- 1 | ID;up__ID;quantity 2 | 2b23bb4b-4ac7-4a24-ac02-aa10cabd842c;3b23bb4b-4ac7-4a24-ac02-aa10cabd842c;10.0 3 | 2b23bb4b-4ac7-4a24-ac02-aa10cabd843c;3b23bb4b-4ac7-4a24-ac02-aa10cabd842c;11.0 4 | -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.bookshop-Order.csv: -------------------------------------------------------------------------------- 1 | ID;netAmount;status;report_ID;header_ID 2 | 0a41a187-a2ff-4df6-bd12-fae8996e6e31;1.0;Post;0a41a666-a2ff-4df6-bd12-fae8996e6666;8567d0de-d44f-11ed-afa1-0242ac120002 3 | 6ac4afbf-deda-45ae-88e6-2883157cc010;2.0;Post;b1a92b71-8ed9-4862-8151-ad951898002f;6b75449a-d44f-11ed-afa1-0242ac120002 4 | 3b23bb4b-4ac7-4a24-ac02-aa10cabd842c;3.0;Post 5 | -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.bookshop-OrderHeader.csv: -------------------------------------------------------------------------------- 1 | ID;status 2 | 6b75449a-d44f-11ed-afa1-0242ac120001;Ordered 3 | 8567d0de-d44f-11ed-afa1-0242ac120001;Shipped 4 | 6b75449a-d44f-11ed-afa1-0242ac120002;Ordered 5 | 8567d0de-d44f-11ed-afa1-0242ac120002;Shipped 6 | -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.bookshop-OrderItem.csv: -------------------------------------------------------------------------------- 1 | ID;quantity;price;order_ID;customer_ID 2 | 9a61178f-bfb3-4c17-8d17-c6b4a63e0097;10.0;5.0;0a41a187-a2ff-4df6-bd12-fae8996e6e31;47f97f40-4f41-488a-b10b-a5725e762d57 3 | ae0d8b10-84cf-4777-a489-a198d1716b61;11.0;6.0;0a41a187-a2ff-4df6-bd12-fae8996e6e31;47f97f40-4f41-488a-b10b-a5725e762d57 4 | -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.bookshop-OrderItemNote.csv: -------------------------------------------------------------------------------- 1 | ID;content;orderItem_ID 2 | a40a9fd8-573d-4f41-1111-fa8ea0d8b1bc;note 1;9a61178f-bfb3-4c17-8d17-c6b4a63e0097 3 | 55bb60e4-ed86-46e6-9378-346153eba8d4;note 2;9a61178f-bfb3-4c17-8d17-c6b4a63e0097 -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.bookshop-PaymentAgreementStatusCodes.csv: -------------------------------------------------------------------------------- 1 | code;name 2 | EXPIRED;Expired 3 | VALID;Valid 4 | VALIDLATER;Valid Later 5 | INACTIVE;Inactive 6 | -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.bookshop-Report.csv: -------------------------------------------------------------------------------- 1 | ID;comment 2 | 0a41a666-a2ff-4df6-bd12-fae8996e6666;some comment 3 | b1a92b71-8ed9-4862-8151-ad951898002f;some report comment -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.bookshop-RootEntity.csv: -------------------------------------------------------------------------------- 1 | ID;name;lifecycleStatus_code;info_ID 2 | 64625905-c234-4d0d-9bc1-283ee8940812;Wuthering Heights;IP;bc21e0d9-a313-4f52-8336-c1be5f88c346 3 | 5ab2a87b-3a56-4d97-a697-7af72334b123;Jane Eyre;CL;bc21e0d9-a313-4f52-8336-c1be5f55d137 4 | 8aaed432-8336-4b0d-be7e-3ef1ce7f14dc;The Raven;AC;bc21e0d9-a313-4f52-8336-c1be5f44f435 5 | -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.bookshop-RootObject.csv: -------------------------------------------------------------------------------- 1 | ID;title 2 | 0a41a187-a2ff-4df6-bd12-fae8996e7e28;RootObject title1 3 | 6ac4afbf-deda-45ae-88e6-2883157cd576;RootObject title2 4 | -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.bookshop-Schools.classes.csv: -------------------------------------------------------------------------------- 1 | ID;up__ID;name;teacher 2 | 9d703c23-54a8-4eff-81c1-cdce6b0528c4;5ab2a87b-3a56-4d97-a697-7af72333c123;History 400;Ms. Davis 3 | 9d703c23-54a8-4eff-81c1-cdec5a0422c3;5ab2a87b-3a56-4d97-a697-7af72333c123;Physics 500;Mrs. Johnson 4 | -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.bookshop-Schools.csv: -------------------------------------------------------------------------------- 1 | ID;name;location 2 | 64625905-c234-4d0d-9bc1-283ee8958331;Sunshine Elementary School;San Francisco 3 | 5ab2a87b-3a56-4d97-a697-7af72333c123;Blue Sky High School;Los Angeles 4 | 8aaed432-8336-4b0d-be7e-3ef1ce7f23ea;Starlight Middle School;San Diego 5 | -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.bookshop-Volumns.csv: -------------------------------------------------------------------------------- 1 | ID;title;sequence;book_ID 2 | dd1fdd7d-da2a-4600-940b-0baf2946c9bf;Wuthering Heights I;1;9d703c23-54a8-4eff-81c1-cdce6b8376b1 -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.common.codelists-BookTypeCodes.csv: -------------------------------------------------------------------------------- 1 | code;name;descr 2 | LIT;Literature;Literature Books 3 | POP;Popular;Popular Books 4 | CUL;Cultural;Cultural Books 5 | LIV;Living;Living Books 6 | MAN;Management;Management Books 7 | SCI;Science;Science Books -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.common.codelists-BookTypeCodes_texts.csv: -------------------------------------------------------------------------------- 1 | locale;code;name;descr 2 | en;LIT;Literature;Literature Books 3 | en;POP;Popular;Popular Books 4 | en;CUL;Cultural;Cultural Books 5 | en;LIV;Living;Living Books 6 | en;MAN;Management;Management Books 7 | en;SCI;Science;Science Books -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.common.codelists-LifecycleStatusCodes.csv: -------------------------------------------------------------------------------- 1 | code;name;criticality 2 | CL;Closed;1 3 | IP;In Preparation;2 4 | AC;Open;3 -------------------------------------------------------------------------------- /tests/bookshop/db/data/sap.capire.common.codelists-LifecycleStatusCodes_texts.csv: -------------------------------------------------------------------------------- 1 | locale;code;name 2 | en;CL;Closed 3 | en;IP;In Preparation 4 | en;AC;Open -------------------------------------------------------------------------------- /tests/bookshop/db/schema.cds: -------------------------------------------------------------------------------- 1 | using { 2 | managed, 3 | cuid, 4 | sap 5 | } from '@sap/cds/common'; 6 | using { 7 | sap.capire.common.types.PersonName as PersonName, 8 | sap.capire.common.types.CountryName as CountryName, 9 | sap.capire.common.types.LifecycleStatusCode as LifecycleStatusCode, 10 | sap.capire.common.types.BookTypeCodes as BookTypeCodes, 11 | } from './common/types.cds'; 12 | using {sap.capire.bookshop.ActivationStatusCode} from './codelists'; 13 | using {sap.capire.bookshop.PaymentAgreementStatusCodes as PaymentAgreementStatusCodes} from './codelists'; 14 | 15 | namespace sap.capire.bookshop; 16 | 17 | @fiori.draft.enabled 18 | @title: '{i18n>RootEntity.objectTitle}' 19 | entity RootEntity @(cds.autoexpose) : managed, cuid { 20 | name : String; 21 | dateTime : DateTime; 22 | timestamp : Timestamp; 23 | lifecycleStatus : LifecycleStatusCode; 24 | child : Composition of many Level1Entity 25 | on child.parent = $self; 26 | info : Association to one AssocOne; 27 | } 28 | 29 | @title: '{i18n>Level1Entity.objectTitle}' 30 | entity Level1Entity : managed, cuid { 31 | title : String; 32 | parent : Association to one RootEntity; 33 | child : Composition of many Level2Entity 34 | on child.parent = $self; 35 | } 36 | 37 | @title: '{i18n>Level2Entity.objectTitle}' 38 | entity Level2Entity : managed, cuid { 39 | title : String; 40 | parent : Association to one Level1Entity; 41 | child : Composition of many Level3Entity 42 | on child.parent = $self; 43 | } 44 | 45 | @title: '{i18n>Level3Entity.objectTitle}' 46 | entity Level3Entity : managed, cuid { 47 | title : String; 48 | parent : Association to one Level2Entity; 49 | } 50 | 51 | entity AssocOne : cuid { 52 | name : String; 53 | info : Association to one AssocTwo; 54 | } 55 | 56 | entity AssocTwo : cuid { 57 | name : String; 58 | info : Association to one AssocThree; 59 | } 60 | 61 | entity AssocThree : cuid { 62 | name : String; 63 | } 64 | 65 | entity RootObject : cuid { 66 | child : Composition of many Level1Object 67 | on child.parent = $self; 68 | title : String; 69 | } 70 | 71 | entity Level1Object : cuid { 72 | parent : Association to one RootObject; 73 | child : Composition of many Level2Object 74 | on child.parent = $self; 75 | title : String; 76 | } 77 | 78 | entity Level2Object : cuid { 79 | title : String; 80 | parent : Association to one Level1Object; 81 | child : Composition of many Level3Object 82 | on child.parent = $self; 83 | } 84 | 85 | entity Level3Object : cuid { 86 | parent : Association to one Level2Object; 87 | title : String; 88 | } 89 | 90 | @fiori.draft.enabled 91 | @title : '{i18n>bookStore.objectTitle}' 92 | entity BookStores @(cds.autoexpose) : managed, cuid { 93 | @title : '{i18n>bookStore.name}' 94 | name : String; 95 | 96 | @title : '{i18n>bookStore.location}' 97 | location : String; 98 | 99 | lifecycleStatus : LifecycleStatusCode; 100 | 101 | @title : '{i18n>bookStore.city}' 102 | city : Association to one City; 103 | 104 | @title : '{i18n>bookStore.books}' 105 | books : Composition of many Books 106 | on books.bookStore = $self; 107 | 108 | @title : '{i18n>bookStore.registry}' 109 | registry : Composition of one BookStoreRegistry; 110 | 111 | @title : '{i18n>bookStore.bookInventory}' 112 | bookInventory : Composition of many { 113 | key ID : UUID; 114 | @changelog 115 | title : String; 116 | } 117 | } 118 | 119 | @fiori.draft.enabled 120 | @title : '{i18n>books.objectTitle}' 121 | entity Books : managed, cuid { 122 | @title : '{i18n>books.title}' 123 | title : localized String(111); 124 | @title : '{i18n>books.descr}' 125 | descr : localized String(1111); 126 | bookStore : Association to one BookStores; 127 | author : Association to one Authors; 128 | @title : '{i18n>books.genre}' 129 | genre : Association to Genres; 130 | stock : Integer; 131 | price : Decimal(11, 4); 132 | isUsed : Boolean; 133 | image : LargeBinary @Core.MediaType : 'image/png'; 134 | @title : '{i18n>books.bookType}' 135 | bookType : BookTypeCodes; 136 | volumns : Composition of many Volumns 137 | on volumns.book = $self; 138 | } 139 | 140 | @title : '{i18n>authors.objectTitle}' 141 | entity Authors : managed, cuid { 142 | @title : '{i18n>authors.name}' 143 | name : PersonName; 144 | dateOfBirth : Date; 145 | dateOfDeath : Date; 146 | @title : '{i18n>authors.placeOfBirth}' 147 | placeOfBirth : String; 148 | placeOfDeath : String; 149 | books : Association to many Books on books.author = $self; 150 | } 151 | 152 | @title : '{i18n>volumns.objectTitle}' 153 | @changelog : [title] 154 | entity Volumns : managed, cuid { 155 | @changelog 156 | @title : '{i18n>volumns.title}' 157 | title : String; 158 | 159 | @changelog 160 | @title : '{i18n>volumns.sequence}' 161 | sequence : Integer; 162 | book : Association to one Books; 163 | @title : '{i18n>Status}' 164 | @changelog : [ActivationStatus.name] 165 | ActivationStatus : Association to one ActivationStatusCode; 166 | PaymentAgreementStatus : Association to one PaymentAgreementStatusCodes on PaymentAgreementStatus.code = ActivationStatus.code; 167 | } 168 | 169 | @title : '{i18n>bookStoreRegistry.objectTitle}' 170 | @changelog : [code] 171 | entity BookStoreRegistry : managed, cuid { 172 | @title : '{i18n>bookStoreRegistry.code}' 173 | code : String; 174 | 175 | @changelog 176 | @title : '{i18n>bookStoreRegistry.validOn}' 177 | validOn : Date; 178 | } 179 | 180 | /** 181 | * Hierarchically organized Code List for Genres 182 | */ 183 | entity Genres : sap.common.CodeList { 184 | key ID : Integer; 185 | parent : Association to Genres; 186 | children : Composition of many Genres 187 | on children.parent = $self; 188 | } 189 | 190 | entity Report : cuid { 191 | orders : Association to many Order 192 | on orders.report = $self; 193 | comment : String; 194 | } 195 | 196 | entity Order : cuid { 197 | @title : '{i18n>title}' 198 | @changelog 199 | title : String; 200 | type : Association to one OrderType; 201 | report : Association to one Report; 202 | header : Composition of one OrderHeader; 203 | orderItems : Composition of many OrderItem 204 | on orderItems.order = $self; 205 | netAmount : Decimal(19, 2); 206 | isUsed : Boolean; 207 | status : String; 208 | Items : Composition of many { 209 | key ID : UUID; 210 | @changelog 211 | quantity : Integer; 212 | } 213 | } 214 | 215 | entity OrderType : cuid { 216 | @title: '{i18n>title}' 217 | @changelog 218 | title : String; 219 | } 220 | 221 | entity Customers : cuid { 222 | name : String; 223 | city : String; 224 | country : String; 225 | age : Integer; 226 | orderItems : Association to many OrderItem 227 | on orderItems.customer = $self; 228 | } 229 | 230 | // do not change-track personal data 231 | annotate Customers with { 232 | name @PersonalData.IsPotentiallyPersonal; 233 | name @changelog 234 | }; 235 | 236 | 237 | entity OrderHeader : cuid { 238 | status : String; 239 | } 240 | 241 | entity OrderItem : cuid { 242 | order : Association to one Order; 243 | customer : Association to one Customers; 244 | notes : Composition of many OrderItemNote 245 | on notes.orderItem = $self; 246 | quantity : Decimal(19, 2); 247 | price : Decimal(19, 2); 248 | } 249 | 250 | entity OrderItemNote : cuid { 251 | orderItem : Association to one OrderItem; 252 | content : String; 253 | @title : '{i18n>Status}' 254 | ActivationStatus : Association to one ActivationStatusCode; 255 | PaymentAgreementStatus : Association to one PaymentAgreementStatusCodes on PaymentAgreementStatus.code = ActivationStatus.code; 256 | } 257 | 258 | entity City : cuid { 259 | name : String; 260 | country : Association to one Country; 261 | } 262 | 263 | entity Country : cuid { 264 | countryName : CountryName; 265 | } 266 | 267 | entity FirstEntity : managed, cuid { 268 | name : String; 269 | children : Association to one Children; 270 | } 271 | 272 | entity SecondEntity : managed, cuid { 273 | name : String; 274 | children : Association to one Children; 275 | } 276 | 277 | @changelog : [one_ID] 278 | entity Children : managed { 279 | @changelog 280 | key one : Association to one FirstEntity; 281 | @changelog 282 | key two : Association to one SecondEntity; 283 | } 284 | 285 | // Test for Unmanaged entity 286 | entity Schools : managed, cuid { 287 | @title: '{i18n>Schools.name}' 288 | name : String; 289 | location : String; 290 | classes : Composition of many { 291 | key ID : UUID; 292 | @title: '{i18n>Classes.name}' 293 | name : String; 294 | @title: '{i18n>Classes.teacher}' 295 | teacher : String; 296 | }; 297 | } 298 | -------------------------------------------------------------------------------- /tests/bookshop/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@cap-js/change-tracking": "*" 4 | }, 5 | "devDependencies": { 6 | "@cap-js/sqlite": "*" 7 | }, 8 | "cds": { 9 | "requires": { 10 | "db": { 11 | "kind": "sql" 12 | } 13 | }, 14 | "features": { 15 | "serve_on_root": true 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /tests/bookshop/srv/admin-service.cds: -------------------------------------------------------------------------------- 1 | using {sap.capire.bookshop as my} from '../db/schema'; 2 | 3 | service AdminService { 4 | @odata.draft.enabled 5 | entity BookStores @(cds.autoexpose) as projection on my.BookStores; 6 | 7 | @odata.draft.enabled 8 | entity RootEntity @(cds.autoexpose) as projection on my.RootEntity; 9 | 10 | @odata.draft.enabled 11 | entity Schools @(cds.autoexpose) as projection on my.Schools; 12 | 13 | entity RootObject as projection on my.RootObject; 14 | entity Level1Object as projection on my.Level1Object; 15 | entity Level2Object as projection on my.Level2Object; 16 | entity Level3Object as projection on my.Level3Object; 17 | entity Level1Entity as projection on my.Level1Entity; 18 | entity Level2Entity as projection on my.Level2Entity; 19 | entity Level3Entity as projection on my.Level3Entity; 20 | entity AssocOne as projection on my.AssocOne; 21 | entity AssocTwo as projection on my.AssocTwo; 22 | entity AssocThree as projection on my.AssocThree; 23 | entity Authors as projection on my.Authors; 24 | entity Report as projection on my.Report; 25 | entity Order as projection on my.Order; 26 | entity Order.Items as projection on my.Order.Items; 27 | entity OrderItem as projection on my.OrderItem; 28 | 29 | entity OrderItemNote as projection on my.OrderItemNote actions { 30 | @cds.odata.bindingparameter.name: 'self' 31 | @Common.SideEffects : {TargetEntities: [self]} 32 | action activate(); 33 | }; 34 | 35 | entity Volumns as projection on my.Volumns actions { 36 | @cds.odata.bindingparameter.name: 'self' 37 | @Common.SideEffects : {TargetEntities: [self]} 38 | action activate(); 39 | }; 40 | 41 | entity Customers as projection on my.Customers; 42 | } 43 | 44 | annotate AdminService.RootEntity with @changelog: [name] { 45 | name @changelog; 46 | child @changelog : [child.child.child.title]; 47 | lifecycleStatus @changelog : [lifecycleStatus.name]; 48 | info @changelog : [info.info.info.name]; 49 | }; 50 | 51 | annotate AdminService.Level1Entity with @changelog: [parent.lifecycleStatus.name] { 52 | title @changelog; 53 | child @changelog : [child.title]; 54 | }; 55 | 56 | annotate AdminService.Level2Entity with @changelog: [parent.parent.lifecycleStatus.name] { 57 | title @changelog; 58 | child @changelog : [child.title]; 59 | }; 60 | 61 | annotate AdminService.Level3Entity with @changelog: [parent.parent.parent.lifecycleStatus.name] { 62 | title @changelog; 63 | } 64 | 65 | annotate AdminService.AssocOne with { 66 | name @changelog; 67 | info @changelog: [info.info.name] 68 | }; 69 | 70 | annotate AdminService.AssocTwo with { 71 | name @changelog; 72 | info @changelog: [info.name] 73 | }; 74 | 75 | annotate AdminService.AssocThree with { 76 | name @changelog; 77 | }; 78 | 79 | annotate AdminService.RootObject with { 80 | title @changelog; 81 | } 82 | 83 | annotate AdminService.Level1Object with { 84 | title @changelog; 85 | child @changelog: [child.title]; 86 | } 87 | 88 | annotate AdminService.Level2Object with { 89 | title @changelog; 90 | child @changelog: [child.title]; 91 | }; 92 | 93 | annotate AdminService.Level3Object with { 94 | title @changelog; 95 | parent @changelog: [parent.parent.parent.title] 96 | }; 97 | 98 | annotate AdminService.Authors with { 99 | name @(Common.Label : '{i18n>serviceAuthors.name}'); 100 | }; 101 | 102 | annotate AdminService.BookStores with @changelog : [name]{ 103 | name @changelog; 104 | location @changelog; 105 | books @changelog : [books.title]; 106 | lifecycleStatus @changelog : [lifecycleStatus.name]; 107 | city @changelog : [ 108 | city.name, 109 | city.country.countryName.code 110 | ] 111 | }; 112 | 113 | 114 | annotate AdminService.Books with @changelog : [ 115 | title, 116 | author.name.firstName, 117 | author.name.lastName 118 | ]{ 119 | title @changelog; 120 | descr @changelog; 121 | isUsed @changelog; 122 | author @changelog : [ 123 | author.name.firstName, 124 | author.name.lastName 125 | ]; 126 | genre @changelog; 127 | bookType @changelog : [ 128 | bookType.name, 129 | bookType.descr 130 | ]; 131 | }; 132 | 133 | annotate AdminService.Authors with @changelog : [ 134 | name.firstName, 135 | name.lastName 136 | ]{ 137 | name @changelog; 138 | placeOfBirth @changelog; 139 | books @changelog : [ 140 | books.name, 141 | books.title 142 | ]; 143 | }; 144 | 145 | annotate AdminService.Order with { 146 | header @changelog; 147 | } 148 | 149 | annotate AdminService.OrderHeader with { 150 | status @changelog; 151 | } 152 | 153 | annotate AdminService.OrderItem with { 154 | quantity @changelog; 155 | customer @changelog : [ 156 | customer.country, 157 | customer.name, 158 | customer.city, 159 | ]; 160 | order @changelog : [ 161 | order.report.comment, 162 | order.status 163 | ]; 164 | } 165 | 166 | annotate AdminService.OrderItemNote with { 167 | content @changelog; 168 | ActivationStatus @changelog : [ActivationStatus.name]; 169 | } 170 | 171 | annotate AdminService.Customers with { 172 | name @changelog; 173 | city @changelog; 174 | country @changelog; 175 | age @changelog; 176 | } 177 | 178 | annotate AdminService.Schools with { 179 | classes @changelog : [classes.name, classes.teacher] 180 | }; 181 | -------------------------------------------------------------------------------- /tests/bookshop/srv/admin-service.js: -------------------------------------------------------------------------------- 1 | const cds = require("@sap/cds"); 2 | 3 | module.exports = cds.service.impl(async (srv) => { 4 | srv.before("CREATE", "BookStores.drafts", async (req) => { 5 | const newBookStores = req.data; 6 | newBookStores.lifecycleStatus_code = "IP"; 7 | }); 8 | srv.before("CREATE", "RootEntity.drafts", async (req) => { 9 | const newRootEntity = req.data; 10 | newRootEntity.lifecycleStatus_code = "IP"; 11 | }); 12 | 13 | const onActivateVolumns = async (req) => { 14 | const entity = req.entity; 15 | const entityID = "dd1fdd7d-da2a-4600-940b-0baf2946c9bf"; 16 | await UPDATE.entity(entity) 17 | .where({ ID: entityID }) 18 | .set({ ActivationStatus_code: "VALID" }); 19 | 20 | const booksEntity = "AdminService.Books"; 21 | const booksID = "676059d4-8851-47f1-b558-3bdc461bf7d5"; 22 | await UPDATE.entity(booksEntity, { ID: booksID }) 23 | .set({ title: "Black Myth wukong" }); 24 | }; 25 | 26 | const onActivateOrderItemNote = async (req) => { 27 | const entity = req.entity; 28 | const entityID = "a40a9fd8-573d-4f41-1111-fa8ea0d8b1bc"; 29 | await UPDATE.entity(entity) 30 | .where({ ID: entityID }) 31 | .set({ ActivationStatus_code: "VALID" }); 32 | 33 | const Level2Object = "AdminService.Level2Object"; 34 | const Level2ObjectID = "55bb60e4-ed86-46e6-9378-346153eba8d4"; 35 | await UPDATE.entity(Level2Object, { ID: Level2ObjectID }) 36 | .set({ title: "Game Science" }); 37 | }; 38 | 39 | srv.on("activate", "AdminService.Volumns", onActivateVolumns); 40 | srv.on("activate", "AdminService.OrderItemNote", onActivateOrderItemNote); 41 | }); 42 | -------------------------------------------------------------------------------- /tests/bookshop/srv/cat-service.cds: -------------------------------------------------------------------------------- 1 | using { sap.capire.bookshop as my } from '../db/schema'; 2 | service CatalogService @(path:'/browse') { 3 | 4 | /** For displaying lists of Books */ 5 | @readonly entity ListOfBooks as projection on Books 6 | excluding { descr }; 7 | 8 | /** For display in details pages */ 9 | @readonly entity Books as projection on my.Books { *, 10 | author.name as author 11 | } excluding { createdBy, modifiedBy }; 12 | 13 | // @requires: 'authenticated-user' 14 | action submitOrder ( book: Books:ID, amount: Integer ) returns { stock: Integer }; 15 | event OrderedBook : { book: Books:ID; amount: Integer; buyer: String }; 16 | } 17 | -------------------------------------------------------------------------------- /tests/integration/service-api.test.js: -------------------------------------------------------------------------------- 1 | const cds = require("@sap/cds"); 2 | const bookshop = require("path").resolve(__dirname, "./../bookshop"); 3 | const { expect, data } = cds.test(bookshop); 4 | 5 | // Enable locale fallback to simulate end user requests 6 | cds.env.features.locale_fallback = true 7 | 8 | jest.setTimeout(5 * 60 * 1000); 9 | 10 | let adminService = null; 11 | let ChangeView = null; 12 | let ChangeLog = null; 13 | let db = null; 14 | 15 | describe("change log integration test", () => { 16 | beforeAll(async () => { 17 | adminService = await cds.connect.to("AdminService"); 18 | db = await cds.connect.to("sql:my.db"); 19 | ChangeView = adminService.entities.ChangeView; 20 | ChangeView["@cds.autoexposed"] = false; 21 | ChangeLog = db.model.definitions["sap.changelog.ChangeLog"]; 22 | }); 23 | 24 | beforeEach(async () => { 25 | await data.reset(); 26 | }); 27 | 28 | it("1.6 When the global switch is on, all changelogs should be retained after the root entity is deleted, and a changelog for the deletion operation should be generated", async () => { 29 | cds.env.requires["change-tracking"].preserveDeletes = true; 30 | 31 | const authorData = [ 32 | { 33 | ID: "64625905-c234-4d0d-9bc1-283ee8940812", 34 | name_firstName: "Sam", 35 | name_lastName: "Smiths", 36 | placeOfBirth: "test place", 37 | } 38 | ] 39 | 40 | await INSERT.into(adminService.entities.Authors).entries(authorData); 41 | const beforeChanges = await adminService.run(SELECT.from(ChangeView)); 42 | expect(beforeChanges.length > 0).to.be.true; 43 | 44 | await DELETE.from(adminService.entities.Authors).where({ ID: "64625905-c234-4d0d-9bc1-283ee8940812" }); 45 | 46 | const afterChanges = await adminService.run(SELECT.from(ChangeView)); 47 | expect(afterChanges.length).to.equal(6); 48 | }); 49 | 50 | it("1.8 When creating or deleting a record with a numeric type of 0 and a boolean type of false, a changelog should also be generated", async () => { 51 | cds.env.requires["change-tracking"].preserveDeletes = true; 52 | cds.services.AdminService.entities.Order.elements.netAmount["@changelog"] = true; 53 | cds.services.AdminService.entities.Order.elements.isUsed["@changelog"] = true; 54 | 55 | const ordersData = { 56 | ID: "0faaff2d-7e0e-4494-97fe-c815ee973fa1", 57 | isUsed: false, 58 | netAmount: 0 59 | }; 60 | 61 | await INSERT.into(adminService.entities.Order).entries(ordersData); 62 | let changes = await adminService.run(SELECT.from(ChangeView)); 63 | 64 | expect(changes).to.have.length(2); 65 | expect( 66 | changes.map((change) => ({ 67 | entityKey: change.entityKey, 68 | entity: change.entity, 69 | valueChangedFrom: change.valueChangedFrom, 70 | valueChangedTo: change.valueChangedTo, 71 | modification: change.modification, 72 | attribute: change.attribute 73 | })) 74 | ).to.have.deep.members([ 75 | { 76 | entityKey: "0faaff2d-7e0e-4494-97fe-c815ee973fa1", 77 | modification: "Create", 78 | entity: "sap.capire.bookshop.Order", 79 | attribute: "netAmount", 80 | valueChangedFrom: "", 81 | valueChangedTo: "0" 82 | }, 83 | { 84 | entityKey: "0faaff2d-7e0e-4494-97fe-c815ee973fa1", 85 | modification: "Create", 86 | entity: "sap.capire.bookshop.Order", 87 | attribute: "isUsed", 88 | valueChangedFrom: "", 89 | valueChangedTo: "false" 90 | }, 91 | ]); 92 | 93 | await DELETE.from(adminService.entities.Order).where({ ID: "0faaff2d-7e0e-4494-97fe-c815ee973fa1" }); 94 | changes = await adminService.run( 95 | SELECT.from(ChangeView).where({ 96 | modification: "delete", 97 | }) 98 | ); 99 | 100 | expect(changes).to.have.length(2); 101 | expect( 102 | changes.map((change) => ({ 103 | entityKey: change.entityKey, 104 | entity: change.entity, 105 | valueChangedFrom: change.valueChangedFrom, 106 | valueChangedTo: change.valueChangedTo, 107 | modification: change.modification, 108 | attribute: change.attribute 109 | })) 110 | ).to.have.deep.members([ 111 | { 112 | entityKey: "0faaff2d-7e0e-4494-97fe-c815ee973fa1", 113 | modification: "Delete", 114 | entity: "sap.capire.bookshop.Order", 115 | attribute: "netAmount", 116 | valueChangedFrom: "0", 117 | valueChangedTo: "" 118 | }, 119 | { 120 | entityKey: "0faaff2d-7e0e-4494-97fe-c815ee973fa1", 121 | modification: "Delete", 122 | entity: "sap.capire.bookshop.Order", 123 | attribute: "isUsed", 124 | valueChangedFrom: "false", 125 | valueChangedTo: "" 126 | }, 127 | ]); 128 | 129 | delete cds.services.AdminService.entities.Order.elements.netAmount["@changelog"]; 130 | delete cds.services.AdminService.entities.Order.elements.isUsed["@changelog"]; 131 | }); 132 | 133 | it("1.9 For DateTime and Timestamp, support for input via Date objects.", async () => { 134 | cds.env.requires["change-tracking"].preserveDeletes = true; 135 | cds.services.AdminService.entities.RootEntity.elements.dateTime["@changelog"] = true; 136 | cds.services.AdminService.entities.RootEntity.elements.timestamp["@changelog"] = true; 137 | const rootEntityData = [ 138 | { 139 | ID: "64625905-c234-4d0d-9bc1-283ee8940717", 140 | dateTime: new Date("2024-10-16T08:53:48Z"), 141 | timestamp: new Date("2024-10-23T08:53:54.000Z") 142 | } 143 | ] 144 | await INSERT.into(adminService.entities.RootEntity).entries(rootEntityData); 145 | let changes = await adminService.run(SELECT.from(ChangeView).where({ 146 | entity: "sap.capire.bookshop.RootEntity", 147 | attribute: "dateTime", 148 | })); 149 | expect(changes.length).to.equal(1); 150 | let change = changes[0]; 151 | expect(change.entityKey).to.equal("64625905-c234-4d0d-9bc1-283ee8940717"); 152 | expect(change.attribute).to.equal("dateTime"); 153 | expect(change.modification).to.equal("Create"); 154 | expect(change.valueChangedFrom).to.equal(""); 155 | /** 156 | * REVISIT: Currently, when using '@cap-js/sqlite' or '@cap-js/hana' and inputting values of type Date in javascript, 157 | * there is an issue with inconsistent formats before and after, which requires a fix from cds-dbs (Issue-873). 158 | */ 159 | expect(change.valueChangedTo).to.equal(`${new Date("2024-10-16T08:53:48Z")}`); 160 | delete cds.services.AdminService.entities.RootEntity.elements.dateTime["@changelog"]; 161 | delete cds.services.AdminService.entities.RootEntity.elements.timestamp["@changelog"]; 162 | cds.env.requires["change-tracking"].preserveDeletes = false; 163 | }); 164 | 165 | it("2.5 Root entity deep creation by service API - should log changes on root entity (ERP4SMEPREPWORKAPPPLAT-32 ERP4SMEPREPWORKAPPPLAT-613)", async () => { 166 | const bookStoreData = { 167 | ID: "843b3681-8b32-4d30-82dc-937cdbc68b3a", 168 | name: "test bookstore name", 169 | location: "test location", 170 | books: [ 171 | { 172 | ID: "f35b2d4c-9b21-4b9a-9b3c-ca1ad32a0d1a", 173 | title: "test title", 174 | descr: "test", 175 | stock: 333, 176 | price: 13.13, 177 | author_ID: "d4d4a1b3-5b83-4814-8a20-f039af6f0387", 178 | }, 179 | ], 180 | }; 181 | 182 | // CAP currently support run queries on the draft-enabled entity on application service, so we can re-enable it. (details in CAP/Issue#16292) 183 | await adminService.run(INSERT.into(adminService.entities.BookStores).entries(bookStoreData)); 184 | 185 | let changes = await SELECT.from(ChangeView).where({ 186 | entity: "sap.capire.bookshop.BookStores", 187 | attribute: "name", 188 | }); 189 | expect(changes.length).to.equal(1); 190 | expect(changes[0].entityKey).to.equal(bookStoreData.ID); 191 | expect(changes[0].objectID).to.equal("test bookstore name"); 192 | 193 | changes = await SELECT.from(ChangeView).where({ 194 | entity: "sap.capire.bookshop.Books", 195 | attribute: "title", 196 | }); 197 | expect(changes.length).to.equal(1); 198 | expect(changes[0].entityKey).to.equal(bookStoreData.ID); 199 | expect(changes[0].objectID).to.equal("test title, Emily, Brontë"); 200 | }); 201 | 202 | it("2.6 Root entity deep update by QL API - should log changes on root entity (ERP4SMEPREPWORKAPPPLAT-32 ERP4SMEPREPWORKAPPPLAT-613)", async () => { 203 | await UPDATE(adminService.entities.BookStores) 204 | .where({ ID: "64625905-c234-4d0d-9bc1-283ee8946770" }) 205 | .with({ 206 | books: [{ ID: "9d703c23-54a8-4eff-81c1-cdce6b8376b1", title: "Wuthering Heights Test" }], 207 | }); 208 | 209 | let changes = await SELECT.from(ChangeView).where({ 210 | entity: "sap.capire.bookshop.Books", 211 | attribute: "title", 212 | }); 213 | 214 | expect(changes.length).to.equal(1); 215 | expect(changes[0].entityKey).to.equal("64625905-c234-4d0d-9bc1-283ee8946770"); 216 | expect(changes[0].objectID).to.equal("Wuthering Heights Test, Emily, Brontë"); 217 | expect(changes[0].parentObjectID).to.equal("Shakespeare and Company"); 218 | }); 219 | 220 | it("3.6 Composition operation of inline entity operation by QL API", async () => { 221 | await UPDATE(adminService.entities["Order.Items"]) 222 | .where({ 223 | up__ID: "3b23bb4b-4ac7-4a24-ac02-aa10cabd842c", 224 | ID: "2b23bb4b-4ac7-4a24-ac02-aa10cabd842c" 225 | }) 226 | .with({ 227 | quantity: 12 228 | }); 229 | 230 | const changes = await adminService.run(SELECT.from(ChangeView)); 231 | 232 | expect(changes.length).to.equal(1); 233 | const change = changes[0]; 234 | expect(change.attribute).to.equal("quantity"); 235 | expect(change.modification).to.equal("Update"); 236 | expect(change.valueChangedFrom).to.equal("10"); 237 | expect(change.valueChangedTo).to.equal("12"); 238 | expect(change.parentKey).to.equal("3b23bb4b-4ac7-4a24-ac02-aa10cabd842c"); 239 | expect(change.keys).to.equal("ID=2b23bb4b-4ac7-4a24-ac02-aa10cabd842c"); 240 | }); 241 | 242 | it("7.3 Annotate fields from chained associated entities as objectID (ERP4SMEPREPWORKAPPPLAT-4542)", async () => { 243 | cds.services.AdminService.entities.BookStores["@changelog"].push({ "=": "city.name" }) 244 | 245 | const bookStoreData = { 246 | ID: "9d703c23-54a8-4eff-81c1-cdce6b6587c4", 247 | name: "new name", 248 | }; 249 | await INSERT.into(adminService.entities.BookStores).entries(bookStoreData); 250 | let createBookStoresChanges = await SELECT.from(ChangeView).where({ 251 | entity: "sap.capire.bookshop.BookStores", 252 | attribute: "name", 253 | modification: "create", 254 | }); 255 | expect(createBookStoresChanges.length).to.equal(1); 256 | const createBookStoresChange = createBookStoresChanges[0]; 257 | expect(createBookStoresChange.objectID).to.equal("new name"); 258 | 259 | await UPDATE(adminService.entities.BookStores) 260 | .where({ 261 | ID: "9d703c23-54a8-4eff-81c1-cdce6b6587c4" 262 | }) 263 | .with({ 264 | name: "BookStores name changed" 265 | }); 266 | const updateBookStoresChanges = await adminService.run( 267 | SELECT.from(ChangeView).where({ 268 | entity: "sap.capire.bookshop.BookStores", 269 | attribute: "name", 270 | modification: "update", 271 | }), 272 | ); 273 | expect(updateBookStoresChanges.length).to.equal(1); 274 | const updateBookStoresChange = updateBookStoresChanges[0]; 275 | expect(updateBookStoresChange.objectID).to.equal("BookStores name changed"); 276 | 277 | cds.services.AdminService.entities.BookStores["@changelog"].pop(); 278 | 279 | const level3EntityData = [ 280 | { 281 | ID: "12ed5dd8-d45b-11ed-afa1-0242ac654321", 282 | title: "Service api Level3 title", 283 | parent_ID: "dd1fdd7d-da2a-4600-940b-0baf2946c4ff", 284 | }, 285 | ]; 286 | await INSERT.into(adminService.entities.Level3Entity).entries(level3EntityData); 287 | let createChanges = await SELECT.from(ChangeView).where({ 288 | entity: "sap.capire.bookshop.Level3Entity", 289 | attribute: "title", 290 | modification: "create", 291 | }); 292 | expect(createChanges.length).to.equal(1); 293 | const createChange = createChanges[0]; 294 | expect(createChange.objectID).to.equal("In Preparation"); 295 | expect(createChange.parentKey).to.equal("dd1fdd7d-da2a-4600-940b-0baf2946c4ff"); 296 | expect(createChange.parentObjectID).to.equal("In Preparation"); 297 | 298 | // Check the changeLog to make sure the entity information is root 299 | const changeLogs = await SELECT.from(ChangeLog).where({ 300 | entity: "sap.capire.bookshop.RootEntity", 301 | entityKey: "64625905-c234-4d0d-9bc1-283ee8940812", 302 | serviceEntity: "AdminService.RootEntity", 303 | }) 304 | 305 | expect(changeLogs.length).to.equal(1); 306 | expect(changeLogs[0].entity).to.equal("sap.capire.bookshop.RootEntity"); 307 | expect(changeLogs[0].entityKey).to.equal("64625905-c234-4d0d-9bc1-283ee8940812"); 308 | expect(changeLogs[0].serviceEntity).to.equal("AdminService.RootEntity"); 309 | 310 | await UPDATE(adminService.entities.Level3Entity, "12ed5dd8-d45b-11ed-afa1-0242ac654321").with({ 311 | title: "L3 title changed by QL API", 312 | }); 313 | let updateChanges = await SELECT.from(ChangeView).where({ 314 | entity: "sap.capire.bookshop.Level3Entity", 315 | attribute: "title", 316 | modification: "update", 317 | }); 318 | expect(createChanges.length).to.equal(1); 319 | const updateChange = updateChanges[0]; 320 | expect(updateChange.objectID).to.equal("In Preparation"); 321 | expect(createChange.parentKey).to.equal("dd1fdd7d-da2a-4600-940b-0baf2946c4ff"); 322 | expect(createChange.parentObjectID).to.equal("In Preparation"); 323 | 324 | await DELETE.from(adminService.entities.Level3Entity).where({ ID: "12ed5dd8-d45b-11ed-afa1-0242ac654321" }); 325 | let deleteChanges = await SELECT.from(ChangeView).where({ 326 | entity: "sap.capire.bookshop.Level3Entity", 327 | attribute: "title", 328 | modification: "delete", 329 | }); 330 | expect(deleteChanges.length).to.equal(1); 331 | const deleteChange = deleteChanges[0]; 332 | expect(deleteChange.objectID).to.equal("In Preparation"); 333 | expect(createChange.parentKey).to.equal("dd1fdd7d-da2a-4600-940b-0baf2946c4ff"); 334 | expect(createChange.parentObjectID).to.equal("In Preparation"); 335 | 336 | // Test object id when parent and child nodes are created at the same time 337 | const RootEntityData = { 338 | ID: "01234567-89ab-cdef-0123-987654fedcba", 339 | name: "New name for RootEntity", 340 | lifecycleStatus_code: "IP", 341 | child: [ 342 | { 343 | ID: "12ed5dd8-d45b-11ed-afa1-0242ac120003", 344 | title: "New name for Level1Entity", 345 | parent_ID: "01234567-89ab-cdef-0123-987654fedcba", 346 | child: [ 347 | { 348 | ID: "12ed5dd8-d45b-11ed-afa1-0242ac124446", 349 | title: "New name for Level2Entity", 350 | parent_ID: "12ed5dd8-d45b-11ed-afa1-0242ac120003" 351 | }, 352 | ], 353 | }, 354 | ], 355 | }; 356 | await INSERT.into(adminService.entities.RootEntity).entries(RootEntityData); 357 | 358 | const createEntityChanges = await adminService.run( 359 | SELECT.from(ChangeView).where({ 360 | entity: "sap.capire.bookshop.Level2Entity", 361 | attribute: "title", 362 | modification: "create", 363 | }), 364 | ); 365 | expect(createEntityChanges.length).to.equal(1); 366 | const createEntityChange = createEntityChanges[0]; 367 | expect(createEntityChange.objectID).to.equal("In Preparation"); 368 | 369 | // Test the object id when the parent node and child node are modified at the same time 370 | await UPDATE(adminService.entities.RootEntity, {ID: "01234567-89ab-cdef-0123-987654fedcba"}) 371 | .with({ 372 | ID: "01234567-89ab-cdef-0123-987654fedcba", 373 | name: "RootEntity name changed", 374 | lifecycleStatus_code: "AC", 375 | child: [ 376 | { 377 | ID: "12ed5dd8-d45b-11ed-afa1-0242ac120003", 378 | parent_ID: "01234567-89ab-cdef-0123-987654fedcba", 379 | child: [ 380 | { 381 | ID: "12ed5dd8-d45b-11ed-afa1-0242ac124446", 382 | parent_ID: "12ed5dd8-d45b-11ed-afa1-0242ac120003", 383 | title : "Level2Entity title changed" 384 | }, 385 | ], 386 | }, 387 | ], 388 | }); 389 | const updateEntityChanges = await adminService.run( 390 | SELECT.from(ChangeView).where({ 391 | entity: "sap.capire.bookshop.Level2Entity", 392 | attribute: "title", 393 | modification: "update", 394 | }), 395 | ); 396 | expect(updateEntityChanges.length).to.equal(1); 397 | const updateEntityChange = updateEntityChanges[0]; 398 | expect(updateEntityChange.objectID).to.equal("Open"); 399 | 400 | // Tests the object id when the parent node update and child node deletion occur simultaneously 401 | await UPDATE(adminService.entities.RootEntity, {ID: "01234567-89ab-cdef-0123-987654fedcba"}) 402 | .with({ 403 | ID: "01234567-89ab-cdef-0123-987654fedcba", 404 | name: "RootEntity name del", 405 | lifecycleStatus_code: "CL", 406 | child: [ 407 | { 408 | ID: "12ed5dd8-d45b-11ed-afa1-0242ac120003", 409 | parent_ID: "01234567-89ab-cdef-0123-987654fedcba", 410 | child: [], 411 | }, 412 | ], 413 | }); 414 | const deleteEntityChanges = await adminService.run( 415 | SELECT.from(ChangeView).where({ 416 | entity: "sap.capire.bookshop.Level2Entity", 417 | attribute: "title", 418 | modification: "delete", 419 | }), 420 | ); 421 | expect(deleteEntityChanges.length).to.equal(1); 422 | const deleteEntityChange = deleteEntityChanges[0]; 423 | expect(deleteEntityChange.objectID).to.equal("Closed"); 424 | }); 425 | 426 | it("8.3 Annotate fields from chained associated entities as displayed value (ERP4SMEPREPWORKAPPPLAT-4542)", async () => { 427 | const rootEntityData = [ 428 | { 429 | ID: "01234567-89ab-cdef-0123-456789dcbafe", 430 | info_ID: "bc21e0d9-a313-4f52-8336-c1be5f88c346", 431 | }, 432 | ]; 433 | await INSERT.into(adminService.entities.RootEntity).entries(rootEntityData); 434 | let createChanges = await SELECT.from(ChangeView).where({ 435 | entity: "sap.capire.bookshop.RootEntity", 436 | attribute: "info", 437 | modification: "create", 438 | }); 439 | expect(createChanges.length).to.equal(1); 440 | const createChange = createChanges[0]; 441 | expect(createChange.valueChangedFrom).to.equal(""); 442 | expect(createChange.valueChangedTo).to.equal("Super Mario1"); 443 | 444 | await UPDATE(adminService.entities.RootEntity, "01234567-89ab-cdef-0123-456789dcbafe").with({ 445 | info_ID: "bc21e0d9-a313-4f52-8336-c1be5f44f435", 446 | }); 447 | 448 | let updateChanges = await SELECT.from(ChangeView).where({ 449 | entity: "sap.capire.bookshop.RootEntity", 450 | attribute: "info", 451 | modification: "update", 452 | }); 453 | expect(updateChanges.length).to.equal(1); 454 | const updateChange = updateChanges[0]; 455 | expect(updateChange.valueChangedFrom).to.equal("Super Mario1"); 456 | expect(updateChange.valueChangedTo).to.equal("Super Mario3"); 457 | }); 458 | 459 | it("10.7 Composition of one node deep created by service API - should log changes on root entity (ERP4SMEPREPWORKAPPPLAT-2913 ERP4SMEPREPWORKAPPPLAT-3063)", async () => { 460 | const bookStoreData = { 461 | ID: "843b3681-8b32-4d30-82dc-937cdbc68b3a", 462 | name: "test bookstore name", 463 | registry: { 464 | ID: "12ed5dd8-d45b-11ed-afa1-0242ac120003", 465 | code: "San Francisco-2", 466 | validOn: "2022-01-01", 467 | }, 468 | }; 469 | 470 | // CAP currently support run queries on the draft-enabled entity on application service, so we can re-enable it. (details in CAP/Issue#16292) 471 | await adminService.run(INSERT.into(adminService.entities.BookStores).entries(bookStoreData)); 472 | 473 | let changes = await SELECT.from(ChangeView).where({ 474 | entity: "sap.capire.bookshop.BookStoreRegistry", 475 | attribute: "validOn", 476 | }); 477 | expect(changes.length).to.equal(1); 478 | expect(changes[0].entityKey).to.equal(bookStoreData.ID); 479 | expect(changes[0].objectID).to.equal("San Francisco-2"); 480 | expect(changes[0].valueChangedFrom).to.equal(""); 481 | expect(changes[0].valueChangedTo).to.equal("2022-01-01"); 482 | expect(changes[0].parentKey).to.equal("843b3681-8b32-4d30-82dc-937cdbc68b3a"); 483 | expect(changes[0].parentObjectID).to.equal("test bookstore name"); 484 | }); 485 | 486 | it("10.8 Composition of one node deep updated by QL API - should log changes on root entity (ERP4SMEPREPWORKAPPPLAT-2913 ERP4SMEPREPWORKAPPPLAT-3063)", async () => { 487 | cds.services.AdminService.entities.BookStoreRegistry["@changelog"] = [ 488 | { "=": "code" }, 489 | { "=": "validOn" }, 490 | ]; 491 | await UPDATE(adminService.entities.BookStores) 492 | .where({ ID: "64625905-c234-4d0d-9bc1-283ee8946770" }) 493 | .with({ 494 | registry: { 495 | ID: "12ed5ac2-d45b-11ed-afa1-0242ac120001", 496 | validOn: "2022-01-01", 497 | }, 498 | }); 499 | 500 | let changes = await SELECT.from(ChangeView).where({ 501 | entity: "sap.capire.bookshop.BookStoreRegistry", 502 | attribute: "validOn", 503 | }); 504 | 505 | expect(changes.length).to.equal(1); 506 | expect(changes[0].entityKey).to.equal("64625905-c234-4d0d-9bc1-283ee8946770"); 507 | expect(changes[0].objectID).to.equal("Paris-1, 2022-01-01"); 508 | expect(changes[0].modification).to.equal("update"); 509 | expect(changes[0].valueChangedFrom).to.equal("2012-01-01"); 510 | expect(changes[0].valueChangedTo).to.equal("2022-01-01"); 511 | expect(changes[0].parentKey).to.equal("64625905-c234-4d0d-9bc1-283ee8946770"); 512 | expect(changes[0].parentObjectID).to.equal("Shakespeare and Company"); 513 | cds.services.AdminService.entities.BookStoreRegistry["@changelog"] = [{ "=": "code" }]; 514 | }); 515 | 516 | it("10.9 Child entity deep delete by QL API - should log changes on root entity (ERP4SMEPREPWORKAPPPLAT-3063)", async () => { 517 | await UPDATE(adminService.entities.BookStores).where({ ID: "64625905-c234-4d0d-9bc1-283ee8946770" }).with({ 518 | registry: null, 519 | registry_ID: null, 520 | }); 521 | 522 | const changes = await SELECT.from(ChangeView).where({ 523 | entity: "sap.capire.bookshop.BookStoreRegistry", 524 | attribute: "validOn", 525 | }); 526 | 527 | expect(changes.length).to.equal(1); 528 | expect(changes[0].entityKey).to.equal("64625905-c234-4d0d-9bc1-283ee8946770"); 529 | expect(changes[0].objectID).to.equal("Paris-1"); 530 | expect(changes[0].modification).to.equal("delete"); 531 | expect(changes[0].parentObjectID).to.equal("Shakespeare and Company"); 532 | expect(changes[0].valueChangedFrom).to.equal("2012-01-01"); 533 | expect(changes[0].valueChangedTo).to.equal(""); 534 | }); 535 | 536 | it(`11.1 "disableUpdateTracking" setting`, async () => { 537 | cds.env.requires["change-tracking"].disableUpdateTracking = true; 538 | await UPDATE(adminService.entities.BookStores) 539 | .where({ID: "64625905-c234-4d0d-9bc1-283ee8946770"}) 540 | .with({name: 'New name'}); 541 | 542 | let changes = await SELECT.from(ChangeView).where({ 543 | entity: "sap.capire.bookshop.BookStores", 544 | attribute: "name", 545 | modification: "update" 546 | }); 547 | expect(changes.length).to.equal(0); 548 | 549 | cds.env.requires["change-tracking"].disableUpdateTracking = false; 550 | await UPDATE(adminService.entities.BookStores) 551 | .where({ID: "64625905-c234-4d0d-9bc1-283ee8946770"}) 552 | .with({name: 'Another name'}); 553 | 554 | changes = await SELECT.from(ChangeView).where({ 555 | entity: "sap.capire.bookshop.BookStores", 556 | attribute: "name", 557 | modification: "update" 558 | }); 559 | expect(changes.length).to.equal(1); 560 | }); 561 | 562 | it(`11.2 "disableCreateTracking" setting`, async () => { 563 | cds.env.requires["change-tracking"].disableCreateTracking = true; 564 | await INSERT.into(adminService.entities.BookStores).entries({ 565 | ID: "9d703c23-54a8-4eff-81c1-cdce6b6587c4", 566 | name: "new name", 567 | }); 568 | 569 | let changes = await SELECT.from(ChangeView).where({ 570 | entity: "sap.capire.bookshop.BookStores", 571 | attribute: "name", 572 | modification: "create", 573 | }); 574 | expect(changes.length).to.equal(0); 575 | 576 | cds.env.requires["change-tracking"].disableCreateTracking = false; 577 | await INSERT.into(adminService.entities.BookStores).entries({ 578 | ID: "04e93234-a5cb-4bfb-89b3-f242ddfaa4ad", 579 | name: "another name", 580 | }); 581 | 582 | changes = await SELECT.from(ChangeView).where({ 583 | entity: "sap.capire.bookshop.BookStores", 584 | attribute: "name", 585 | modification: "create", 586 | }); 587 | expect(changes.length).to.equal(1); 588 | }); 589 | 590 | it(`11.3 "disableDeleteTracking" setting`, async () => { 591 | cds.env.requires["change-tracking"].disableDeleteTracking = true; 592 | await DELETE.from(adminService.entities.Level3Entity) 593 | .where({ID: "12ed5dd8-d45b-11ed-afa1-0242ac654321"}); 594 | 595 | let changes = await SELECT.from(ChangeView).where({ 596 | entity: "sap.capire.bookshop.Level3Entity", 597 | attribute: "title", 598 | modification: "delete", 599 | }); 600 | expect(changes.length).to.equal(0); 601 | 602 | cds.env.requires["change-tracking"].disableDeleteTracking = false; 603 | await DELETE.from(adminService.entities.Level2Entity) 604 | .where({ID: "dd1fdd7d-da2a-4600-940b-0baf2946c4ff"}); 605 | 606 | changes = await SELECT.from(ChangeView).where({ 607 | entity: "sap.capire.bookshop.Level2Entity", 608 | attribute: "title", 609 | modification: "delete", 610 | }); 611 | expect(changes.length).to.equal(1); 612 | }); 613 | 614 | it("Do not change track personal data", async () => { 615 | const allCustomers = await SELECT.from(adminService.entities.Customers); 616 | await UPDATE(adminService.entities.Customers).where({ ID: allCustomers[0].ID }).with({ 617 | name: 'John Doe', 618 | }); 619 | 620 | const changes = await SELECT.from(ChangeView).where({ 621 | entity: "sap.capire.bookshop.Customers", 622 | }); 623 | 624 | expect(changes.length).to.equal(0); 625 | }); 626 | 627 | it("When creating multiple root records, change tracking for each entity should also be generated", async () => { 628 | cds.env.requires["change-tracking"].preserveDeletes = true; 629 | cds.services.AdminService.entities.Order.elements.netAmount["@changelog"] = true; 630 | cds.services.AdminService.entities.Order.elements.isUsed["@changelog"] = true; 631 | 632 | const ordersData = [ 633 | { 634 | ID: "fa4d0140-efdd-4c32-aafd-efb7f1d0c8e1", 635 | isUsed: false, 636 | netAmount: 0, 637 | orderItems: [ 638 | { 639 | ID: "f35b2d4c-9b21-4b9a-9b3c-ca1ad32a0d1a", 640 | quantity: 10, 641 | }, 642 | { 643 | ID: "f35b2d4c-9b21-4b9a-9b3c-ca1ad32a1c2b", 644 | quantity: 12, 645 | } 646 | ], 647 | }, 648 | { 649 | ID: "ec365b25-b346-4444-8f03-8f5b7d94f040", 650 | isUsed: true, 651 | netAmount: 10, 652 | orderItems: [ 653 | { 654 | ID: "f35b2d4c-9b21-4b9a-9b3c-ca1ad32a2c2a", 655 | quantity: 10, 656 | }, 657 | { 658 | ID: "f35b2d4c-9b21-4b9a-9b3c-ca1ad32a2b3b", 659 | quantity: 12, 660 | } 661 | ], 662 | }, 663 | { 664 | ID: "ab9e5510-a60b-4dfc-b026-161c5c2d4056", 665 | isUsed: false, 666 | netAmount: 20, 667 | orderItems: [ 668 | { 669 | ID: "f35b2d4c-9b21-4b9a-9b3c-ca1ad32a2c1a", 670 | quantity: 10, 671 | }, 672 | { 673 | ID: "f35b2d4c-9b21-4b9a-9b3c-ca1ad32a4c1b", 674 | quantity: 12, 675 | } 676 | ], 677 | } 678 | ]; 679 | 680 | await INSERT.into(adminService.entities.Order).entries(ordersData); 681 | let changes = await adminService.run(SELECT.from(ChangeView)); 682 | 683 | expect(changes).to.have.length(12); 684 | expect( 685 | changes.map((change) => ({ 686 | entityKey: change.entityKey, 687 | entity: change.entity, 688 | valueChangedFrom: change.valueChangedFrom, 689 | valueChangedTo: change.valueChangedTo, 690 | modification: change.modification, 691 | attribute: change.attribute 692 | })) 693 | ).to.have.deep.members([ 694 | { 695 | entityKey: "fa4d0140-efdd-4c32-aafd-efb7f1d0c8e1", 696 | modification: "Create", 697 | entity: "sap.capire.bookshop.Order", 698 | attribute: "netAmount", 699 | valueChangedFrom: "", 700 | valueChangedTo: "0" 701 | }, 702 | { 703 | entityKey: "fa4d0140-efdd-4c32-aafd-efb7f1d0c8e1", 704 | modification: "Create", 705 | entity: "sap.capire.bookshop.Order", 706 | attribute: "isUsed", 707 | valueChangedFrom: "", 708 | valueChangedTo: "false" 709 | }, 710 | { 711 | entityKey: "fa4d0140-efdd-4c32-aafd-efb7f1d0c8e1", 712 | modification: "Create", 713 | entity: "sap.capire.bookshop.OrderItem", 714 | attribute: "quantity", 715 | valueChangedFrom: "", 716 | valueChangedTo: "10" 717 | }, 718 | { 719 | entityKey: "fa4d0140-efdd-4c32-aafd-efb7f1d0c8e1", 720 | modification: "Create", 721 | entity: "sap.capire.bookshop.OrderItem", 722 | attribute: "quantity", 723 | valueChangedFrom: "", 724 | valueChangedTo: "12" 725 | }, 726 | { 727 | entityKey: "ec365b25-b346-4444-8f03-8f5b7d94f040", 728 | modification: "Create", 729 | entity: "sap.capire.bookshop.Order", 730 | attribute: "netAmount", 731 | valueChangedFrom: "", 732 | valueChangedTo: "10" 733 | }, 734 | { 735 | entityKey: "ec365b25-b346-4444-8f03-8f5b7d94f040", 736 | modification: "Create", 737 | entity: "sap.capire.bookshop.Order", 738 | attribute: "isUsed", 739 | valueChangedFrom: "", 740 | valueChangedTo: "true" 741 | }, 742 | { 743 | entityKey: "ec365b25-b346-4444-8f03-8f5b7d94f040", 744 | modification: "Create", 745 | entity: "sap.capire.bookshop.OrderItem", 746 | attribute: "quantity", 747 | valueChangedFrom: "", 748 | valueChangedTo: "10" 749 | }, 750 | { 751 | entityKey: "ec365b25-b346-4444-8f03-8f5b7d94f040", 752 | modification: "Create", 753 | entity: "sap.capire.bookshop.OrderItem", 754 | attribute: "quantity", 755 | valueChangedFrom: "", 756 | valueChangedTo: "12" 757 | }, 758 | { 759 | entityKey: "ab9e5510-a60b-4dfc-b026-161c5c2d4056", 760 | modification: "Create", 761 | entity: "sap.capire.bookshop.Order", 762 | attribute: "netAmount", 763 | valueChangedFrom: "", 764 | valueChangedTo: "20" 765 | }, 766 | { 767 | entityKey: "ab9e5510-a60b-4dfc-b026-161c5c2d4056", 768 | modification: "Create", 769 | entity: "sap.capire.bookshop.Order", 770 | attribute: "isUsed", 771 | valueChangedFrom: "", 772 | valueChangedTo: "false" 773 | }, 774 | { 775 | entityKey: "ab9e5510-a60b-4dfc-b026-161c5c2d4056", 776 | modification: "Create", 777 | entity: "sap.capire.bookshop.OrderItem", 778 | attribute: "quantity", 779 | valueChangedFrom: "", 780 | valueChangedTo: "10" 781 | }, 782 | { 783 | entityKey: "ab9e5510-a60b-4dfc-b026-161c5c2d4056", 784 | modification: "Create", 785 | entity: "sap.capire.bookshop.OrderItem", 786 | attribute: "quantity", 787 | valueChangedFrom: "", 788 | valueChangedTo: "12" 789 | } 790 | ]); 791 | 792 | cds.env.requires["change-tracking"].preserveDeletes = false; 793 | delete cds.services.AdminService.entities.Order.elements.netAmount["@changelog"]; 794 | delete cds.services.AdminService.entities.Order.elements.isUsed["@changelog"]; 795 | }); 796 | }); 797 | -------------------------------------------------------------------------------- /tests/unit/util.test.js: -------------------------------------------------------------------------------- 1 | const cds = require("@sap/cds"); 2 | const { expect } = cds.test 3 | const templateProcessor = require("../../lib/template-processor"); 4 | const { getEntityByContextPath } = require("../../lib/entity-helper"); 5 | 6 | // Enable locale fallback to simulate end user requests 7 | cds.env.features.locale_fallback = true 8 | 9 | const _processorFn = (changeMap) => { 10 | return ({ row, key, element }) => { 11 | if (!row || !key || !element) { 12 | return; 13 | } 14 | 15 | changeMap.get("test-entity").push({}); 16 | }; 17 | }; 18 | 19 | describe("templateProcessor", () => { 20 | it("should return undefined if template processor get null sub rows (ERP4SMEPREPWORKAPPPLAT-32)", async () => { 21 | const changeMap = new Map(); 22 | const elements = new Map(); 23 | const diff = { _op: "Delete", test: "test", subRow: [{ _op: "Delete", test: "test" }] }; 24 | elements.set("test", { 25 | template: { elements: [], target: { elements: elements, keys: [] } }, 26 | picked: (element) => { 27 | return element["@changelog"]; 28 | }, 29 | }); 30 | const template = { elements: elements, target: { elements: elements, keys: [] } }; 31 | const pathOptions = { 32 | segments: [{ includeKeyValues: true }], 33 | includeKeyValues: true, 34 | rowKeysGenerator: () => { 35 | return; 36 | }, 37 | }; 38 | const args = { processFn: _processorFn(changeMap), row: diff, template, isRoot: true, pathOptions }; 39 | expect(templateProcessor(args)).to.equal(undefined); 40 | }); 41 | }); 42 | 43 | describe("entityHelper", () => { 44 | cds.model = {definitions:{}} 45 | 46 | it("1.0 should return null if content path not exist (ERP4SMEPREPWORKAPPPLAT-32)", async () => { 47 | expect(getEntityByContextPath("".split('/'))).to.not.exist; 48 | }); 49 | 50 | it("1.2 should return false if composition not found (ERP4SMEPREPWORKAPPPLAT-32)", async () => { 51 | const parentEntity = { compositions: [{ target: "child_entity1" }] }; 52 | const subEntity = { name: "child_entity2" }; 53 | let hasComposition = Object.values(parentEntity.compositions).some(c => c._target === subEntity) 54 | expect(hasComposition).to.equal(false); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /tests/utils/api.js: -------------------------------------------------------------------------------- 1 | class RequestSend { 2 | constructor(post) { 3 | this.post = post; 4 | } 5 | async apiAction(serviceName, entityName, id, path, action, isRootCreated = false) { 6 | if (!isRootCreated) { 7 | await this.post(`/odata/v4/${serviceName}/${entityName}(ID=${id},IsActiveEntity=true)/${path}.draftEdit`, { 8 | PreserveChanges: true, 9 | }); 10 | } 11 | await action(); 12 | await this.post(`/odata/v4/${serviceName}/${entityName}(ID=${id},IsActiveEntity=false)/${path}.draftPrepare`, { 13 | SideEffectsQualifier: "", 14 | }); 15 | await this.post(`/odata/v4/${serviceName}/${entityName}(ID=${id},IsActiveEntity=false)/${path}.draftActivate`, {}); 16 | } 17 | } 18 | 19 | module.exports = { 20 | RequestSend, 21 | }; 22 | --------------------------------------------------------------------------------